Merge branch 'develop' into feat/588-vv-fremdeinschatzung

This commit is contained in:
Livio Bieri 2024-01-29 16:41:46 +01:00
commit 95a5d1b671
57 changed files with 1183 additions and 302 deletions

View File

@ -0,0 +1,10 @@
<template>
<div>
<div class="flex-none border-r bg-white p-4 lg:p-8">
<slot name="side"></slot>
</div>
<div class="flex-1 p-4 lg:p-8">
<slot name="main"></slot>
</div>
</div>
</template>

View File

@ -17,7 +17,7 @@ const 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": 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 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 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 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 first_name\n last_name\n }\n assignment_user {\n avatar_url\n first_name\n last_name\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 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 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 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 feedback_user\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 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 feedback_user\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,
@ -60,7 +60,7 @@ export function graphql(source: "\n query attendanceCheckQuery($courseSessionId
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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 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"): (typeof documents)["\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"]; export function graphql(source: "\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 first_name\n last_name\n }\n assignment_user {\n avatar_url\n first_name\n last_name\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"): (typeof documents)["\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 first_name\n last_name\n }\n assignment_user {\n avatar_url\n first_name\n last_name\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"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * 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

@ -66,8 +66,13 @@ export const ASSIGNMENT_COMPLETION_QUERY = graphql(`
evaluation_submitted_at evaluation_submitted_at
evaluation_user { evaluation_user {
id id
first_name
last_name
} }
assignment_user { assignment_user {
avatar_url
first_name
last_name
id id
} }
evaluation_points evaluation_points

View File

@ -1,116 +0,0 @@
<script setup lang="ts">
import * as log from "loglevel";
import { computed, onMounted } from "vue";
import CompetenceDetail from "@/pages/competence/ActionCompetenceDetail.vue";
import LearningPathPathView from "@/pages/learningPath/learningPathPage/LearningPathPathView.vue";
import {
useCourseDataWithCompletion,
useCourseSessionDetailQuery,
} from "@/composables";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
const props = defineProps<{
userId: string;
courseSlug: string;
}>();
log.debug("CockpitUserProfilePage created", props.userId);
const { loading } = useExpertCockpitPageData(props.courseSlug);
const courseCompletionData = useCourseDataWithCompletion(
props.courseSlug,
props.userId
);
onMounted(async () => {
log.debug("CockpitUserProfilePage mounted");
});
const lpQueryResult = useCourseDataWithCompletion(props.courseSlug, props.userId);
const learningPath = computed(() => {
return lpQueryResult.learningPath.value;
});
const { findUser } = useCourseSessionDetailQuery();
const user = computed(() => {
return findUser(props.userId);
});
function setActiveClasses(isActive: boolean) {
return isActive ? ["border-blue-900", "border-b-2"] : ["text-bg-900"];
}
</script>
<template>
<div v-if="!loading" class="bg-gray-200">
<div v-if="user" class="container-large">
<nav class="py-4 pb-4">
<router-link
class="btn-text inline-flex items-center pl-0"
:to="`/course/${props.courseSlug}/cockpit`"
>
<it-icon-arrow-left />
<span>{{ $t("general.back") }}</span>
</router-link>
</nav>
<header class="mb-12 flex flex-row items-center">
<img class="mr-8 h-44 w-44 rounded-full" :src="user.avatar_url" />
<div>
<h1 class="mb-2">{{ user.first_name }} {{ user.last_name }}</h1>
<p class="mb-2">{{ user.email }}</p>
<p class="bg-message bg-[center_left_-4px] bg-no-repeat pl-6">
{{ $t("messages.sendMessage") }}
</p>
</div>
</header>
<main>
<div v-if="learningPath" class="mb-8 w-full bg-white pb-1 pt-2">
<!-- TODO: rework this section with next redesign -->
<LearningPathPathView
:use-mobile-layout="false"
:hide-buttons="true"
:learning-path="learningPath"
:next-learning-content="undefined"
:override-circle-url-base="`/course/${props.courseSlug}/cockpit/profile/${props.userId}`"
></LearningPathPathView>
</div>
<ul class="mb-5 flex flex-row border-b-2">
<li class="relative top-px mr-12 pb-3" :class="setActiveClasses(true)">
<button>{{ $t("competences.competences") }}</button>
</li>
<li class="mr-12">
<button>{{ $t("general.transferTask_other") }}</button>
</li>
<li class="mr-12">
<button>{{ $t("general.exam_other") }}</button>
</li>
<li class="mr-12">
<button>{{ $t("general.certificate_other") }}</button>
</li>
</ul>
<div>
<ul class="bg-white px-8">
<li
v-for="competence in courseCompletionData.actionCompetences.value ?? []"
:key="competence.id"
class="border-b border-gray-500 p-8 last:border-0"
>
<CompetenceDetail
:competence="competence"
:course-slug="props.courseSlug"
:show-assess-again="false"
:is-inline="true"
></CompetenceDetail>
</li>
</ul>
</div>
</main>
</div>
</div>
</template>
<style scoped></style>

View File

@ -1,16 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { useCourseSessionDetailQuery, useCurrentCourseSession } from "@/composables"; import { useCurrentCourseSession } from "@/composables";
import { ASSIGNMENT_COMPLETION_QUERY } from "@/graphql/queries"; import { ASSIGNMENT_COMPLETION_QUERY } from "@/graphql/queries";
import EvaluationContainer from "@/pages/cockpit/assignmentEvaluationPage/EvaluationContainer.vue"; import EvaluationContainer from "@/pages/cockpit/assignmentEvaluationPage/EvaluationContainer.vue";
import AssignmentSubmissionResponses from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionResponses.vue"; import AssignmentSubmissionResponses from "@/pages/learningPath/learningContentPage/assignment/AssignmentSubmissionResponses.vue";
import type { Assignment, AssignmentCompletion } from "@/types"; import type { Assignment, AssignmentCompletion, CourseSessionUser } from "@/types";
import { useQuery } from "@urql/vue"; import { useQuery } from "@urql/vue";
import log from "loglevel"; import log from "loglevel";
import { computed, onMounted } from "vue"; import { computed, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { getPreviousRoute } from "@/router/history"; import { getPreviousRoute } from "@/router/history";
import { getAssignmentTypeTitle } from "../../../utils/utils"; import { getAssignmentTypeTitle } from "../../../utils/utils";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
const props = defineProps<{ const props = defineProps<{
courseSlug: string; courseSlug: string;
@ -20,7 +19,6 @@ const props = defineProps<{
log.debug("AssignmentEvaluationPage created", props.assignmentId, props.userId); log.debug("AssignmentEvaluationPage created", props.assignmentId, props.userId);
const { loading } = useExpertCockpitPageData(props.courseSlug);
const courseSession = useCurrentCourseSession(); const courseSession = useCurrentCourseSession();
const router = useRouter(); const router = useRouter();
@ -38,9 +36,6 @@ onMounted(async () => {
log.debug("AssignmentView mounted", props.assignmentId, props.userId); log.debug("AssignmentView mounted", props.assignmentId, props.userId);
}); });
const courseSessionDetailResult = useCourseSessionDetailQuery();
const assignmentUser = computed(() => courseSessionDetailResult.findUser(props.userId));
const previousRoute = getPreviousRoute(); const previousRoute = getPreviousRoute();
function close() { function close() {
@ -58,13 +53,17 @@ const assignmentCompletion = computed(
queryResult.data.value?.assignment_completion as AssignmentCompletion | undefined queryResult.data.value?.assignment_completion as AssignmentCompletion | undefined
); );
const assignmentUser = computed(
() => assignmentCompletion.value?.assignment_user as CourseSessionUser | undefined
);
const assignment = computed( const assignment = computed(
() => queryResult.data.value?.assignment as Assignment | undefined () => queryResult.data.value?.assignment as Assignment | undefined
); );
</script> </script>
<template> <template>
<div v-if="!loading" class="absolute bottom-0 top-0 z-10 w-full bg-white"> <div class="absolute bottom-0 top-0 z-10 w-full bg-white">
<div v-if="queryResult.fetching.value"></div> <div v-if="queryResult.fetching.value"></div>
<div v-else-if="queryResult.error.value">{{ queryResult.error.value }}</div> <div v-else-if="queryResult.error.value">{{ queryResult.error.value }}</div>
<div v-else> <div v-else>
@ -101,12 +100,12 @@ const assignment = computed(
<div class="my-6 flex items-center"> <div class="my-6 flex items-center">
<img <img
:src="assignmentUser?.avatar_url" :src="assignmentUser.avatar_url"
class="mr-4 h-11 w-11 rounded-full" class="mr-4 h-11 w-11 rounded-full"
/> />
<div class="font-bold"> <div class="font-bold">
{{ assignmentUser?.first_name }} {{ assignmentUser.first_name }}
{{ assignmentUser?.last_name }} {{ assignmentUser.last_name }}
</div> </div>
</div> </div>
<AssignmentSubmissionResponses <AssignmentSubmissionResponses

View File

@ -65,7 +65,9 @@ const assignmentDetail = computed(() => {
}); });
const dueDate = computed(() => const dueDate = computed(() =>
dayjs(assignmentDetail.value?.evaluation_deadline?.start) assignmentDetail.value?.evaluation_deadline?.start
? dayjs(assignmentDetail.value?.evaluation_deadline?.start)
: undefined
); );
const inEvaluationTask = computed( const inEvaluationTask = computed(

View File

@ -11,7 +11,7 @@ const props = defineProps<{
assignmentUser: CourseSessionUser; assignmentUser: CourseSessionUser;
assignment: Assignment; assignment: Assignment;
assignmentCompletion: AssignmentCompletion; assignmentCompletion: AssignmentCompletion;
dueDate?: Dayjs; dueDate?: Dayjs | undefined;
}>(); }>();
const emit = defineEmits(["startEvaluation"]); const emit = defineEmits(["startEvaluation"]);
@ -58,7 +58,7 @@ async function startEvaluation() {
upsertAssignmentCompletionMutation.executeMutation({ upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignment.id, assignmentId: props.assignment.id,
courseSessionId: courseSession.value.id, courseSessionId: courseSession.value.id,
assignmentUserId: props.assignmentUser.user_id, assignmentUserId: props.assignmentUser.id,
completionStatus: "EVALUATION_IN_PROGRESS", completionStatus: "EVALUATION_IN_PROGRESS",
completionDataString: JSON.stringify({}), completionDataString: JSON.stringify({}),
// next line used for urql // next line used for urql
@ -101,7 +101,13 @@ async function startEvaluation() {
</p> </p>
<p class="my-4" data-cy="instruction"> <p class="my-4" data-cy="instruction">
{{ $t(text.evaluationInstruction) }} {{
$t(text.evaluationInstruction, {
name: `${
props.assignmentUser.first_name + " " + props.assignmentUser.last_name
}`,
})
}}
</p> </p>
<p v-if="props.assignment.assignment_type === 'CASEWORK'" class="my-4"> <p v-if="props.assignment.assignment_type === 'CASEWORK'" class="my-4">

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue"; import ItSuccessAlert from "@/components/ui/ItSuccessAlert.vue";
import { useCourseSessionDetailQuery, useCurrentCourseSession } from "@/composables"; import { useCurrentCourseSession } from "@/composables";
import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations"; import { UPSERT_ASSIGNMENT_COMPLETION_MUTATION } from "@/graphql/mutations";
import { import {
maxAssignmentPoints, maxAssignmentPoints,
@ -75,7 +75,7 @@ async function submitEvaluation() {
upsertAssignmentCompletionMutation.executeMutation({ upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignment.id, assignmentId: props.assignment.id,
courseSessionId: courseSession.value.id, courseSessionId: courseSession.value.id,
assignmentUserId: props.assignmentUser.user_id, assignmentUserId: props.assignmentUser.id,
completionStatus: "EVALUATION_SUBMITTED", completionStatus: "EVALUATION_SUBMITTED",
completionDataString: JSON.stringify({}), completionDataString: JSON.stringify({}),
evaluationPoints: userPoints.value, evaluationPoints: userPoints.value,
@ -110,26 +110,19 @@ const maxPoints = computed(() => maxAssignmentPoints(props.assignment));
const userPoints = computed(() => const userPoints = computed(() =>
userAssignmentPoints(props.assignment, props.assignmentCompletion) userAssignmentPoints(props.assignment, props.assignmentCompletion)
); );
const courseSessionDetailResult = useCourseSessionDetailQuery();
const evaluationUser = computed(() => {
if (props.assignmentCompletion.evaluation_user) {
return courseSessionDetailResult.findUser(
props.assignmentCompletion.evaluation_user?.id
);
}
return undefined;
});
</script> </script>
<template> <template>
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<div> <div>
<h3 v-if="evaluationUser && props.showEvaluationUser" class="mb-6"> <h3
v-if="assignmentCompletion.evaluation_user && props.showEvaluationUser"
class="mb-6"
>
{{ {{
$t(text.evaluationFromUser, { $t(text.evaluationFromUser, {
x: evaluationUser?.first_name, x: assignmentCompletion.evaluation_user.first_name,
y: evaluationUser?.last_name, y: assignmentCompletion.evaluation_user.last_name,
}) })
}} }}
</h3> </h3>

View File

@ -91,7 +91,7 @@ async function evaluateAssignmentCompletion(completionData: AssignmentCompletion
upsertAssignmentCompletionMutation.executeMutation({ upsertAssignmentCompletionMutation.executeMutation({
assignmentId: props.assignment.id, assignmentId: props.assignment.id,
courseSessionId: courseSession.value.id, courseSessionId: courseSession.value.id,
assignmentUserId: props.assignmentUser.user_id, assignmentUserId: props.assignmentUser.id,
completionStatus: "EVALUATION_IN_PROGRESS", completionStatus: "EVALUATION_IN_PROGRESS",
completionDataString: JSON.stringify(completionData), completionDataString: JSON.stringify(completionData),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment

View File

@ -27,9 +27,12 @@ const { summary } = useMentorCockpit(courseSession.value.id);
{{ participant.email }} {{ participant.email }}
</div> </div>
</div> </div>
<!-- <router-link :to="{name: 'cockpitUserProfile', params: {userId: participant.id}}" class="underline">--> <router-link
<!-- {{ $t("a.Profil anzeigen") }}--> :to="{ name: 'cockpitUserProfile', params: { userId: participant.id } }"
<!-- </router-link>--> class="underline"
>
{{ $t("cockpit.profileLink") }}
</router-link>
</div> </div>
</div> </div>
</template> </template>

View File

@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Assignment, Participant } from "@/services/mentorCockpit"; import type { Assignment, Participant } from "@/services/mentorCockpit";
import { useMentorCockpit } from "@/services/mentorCockpit"; import { useMentorCockpit } from "@/services/mentorCockpit";
import { computed, type Ref } from "vue"; import { computed, onMounted, type Ref } from "vue";
import { useCurrentCourseSession } from "@/composables"; import { useCurrentCourseSession } from "@/composables";
import log from "loglevel";
const props = defineProps<{ const props = defineProps<{
praxisAssignmentId: string; praxisAssignmentId: string;
@ -10,20 +11,19 @@ const props = defineProps<{
const courseSession = useCurrentCourseSession(); const courseSession = useCurrentCourseSession();
const mentorCockpitStore = useMentorCockpit(courseSession.value.id); const mentorCockpitStore = useMentorCockpit(courseSession.value.id);
const participants = computed(() => mentorCockpitStore.summary.value?.participants);
const praxisAssignment: Ref<Assignment | null> = computed(() => const praxisAssignment: Ref<Assignment | null> = computed(() =>
mentorCockpitStore.getAssignmentById(props.praxisAssignmentId) mentorCockpitStore.getAssignmentById(props.praxisAssignmentId)
); );
const getParticipantById = (id: string): Participant | null => { const getParticipantById = (id: string): Participant | null => {
if (mentorCockpitStore.summary.value?.participants) { return participants.value?.find((participant) => participant.id === id) || null;
const found = mentorCockpitStore.summary.value.participants.find(
(item) => item.id === id
);
return found || null;
}
return null;
}; };
onMounted(() => {
log.debug("MentorPraxisAssignment mounted");
mentorCockpitStore.fetchData();
});
</script> </script>
<template> <template>
@ -96,7 +96,22 @@ const getParticipantById = (id: string): Participant | null => {
</template> </template>
</div> </div>
<!-- Right --> <!-- Right -->
<div></div> <div>
<router-link
v-if="item.status == 'SUBMITTED'"
class="btn-primary"
:to="item.url"
>
{{ $t("a.Ergebnis bewerten") }}
</router-link>
<router-link
v-else-if="item.status == 'EVALUATED'"
class="underline"
:to="item.url"
>
{{ $t("a.Bewertung ansehen") }}
</router-link>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTranslation } from "i18next-vue";
import { useFetch } from "@vueuse/core";
import { useCurrentCourseSession } from "@/composables";
const props = defineProps<{
userId: string;
courseSlug: string;
}>();
const { t } = useTranslation();
const pages = ref([
{ label: t("general.learningPath"), route: "cockpitProfileLearningPath" },
]);
const courseSession = useCurrentCourseSession();
const { data: user } = useFetch(
`/api/core/profile/${courseSession.value.id}/${props.userId}`
).json();
const route = useRoute();
const router = useRouter();
onMounted(() => {
// if current route name not in pages, redirect to first page
if (route.name && !pages.value.find((page) => page.route === route.name)) {
router.push({
name: pages.value[0].route,
params: { userId: props.userId, courseSlug: props.courseSlug },
});
}
});
</script>
<template>
<div v-if="user" class="flex flex-col bg-gray-200">
<div class="relative border-b bg-white shadow-md">
<div class="container-large pb-0">
<router-link
class="btn-text inline-flex items-center pl-0"
:to="`/course/${props.courseSlug}/cockpit`"
>
<it-icon-arrow-left />
<span>{{ $t("general.back") }}</span>
</router-link>
<div class="mb-12 mt-2 flex items-center">
<img class="mr-8 h-48 w-48 rounded-full" :src="user.avatar_url" />
<div class="flex flex-col">
<h2 class="mb-2">{{ user.first_name }} {{ user.last_name }}</h2>
<p class="mb-2">{{ user.email }}</p>
<p class="text-gray-800">{{ $t("a.Teilnehmer") }}</p>
</div>
</div>
</div>
<ul class="flex flex-row px-4 lg:px-8">
<li
v-for="page in pages"
:key="page.route"
class="relative top-px mr-12 pb-3"
:class="[route.name === page.route ? 'border-b-2 border-blue-900 pb-3' : '']"
>
<router-link :to="{ name: page.route }">
{{ page.label }}
</router-link>
</li>
</ul>
</div>
<router-view class="flex flex-grow py-0" />
</div>
</template>

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import { calculateCircleSectorData } from "@/pages/learningPath/learningPathPage/utils";
import { useCourseDataWithCompletion } from "@/composables";
import CockpitProfileContent from "@/components/cockpit/profile/CockpitProfileContent.vue";
import LearningPathCircle from "@/pages/learningPath/learningPathPage/LearningPathCircle.vue";
import LearningSequence from "@/pages/learningPath/circlePage/LearningSequence.vue";
import { ref, watch } from "vue";
import type { CircleType } from "@/types";
const props = defineProps<{
userId: string;
courseSlug: string;
}>();
const selectedCircle = ref();
const lpQueryResult = useCourseDataWithCompletion(props.courseSlug, props.userId);
function selectCircle(circle: CircleType) {
selectedCircle.value = circle;
}
watch(lpQueryResult.learningPath, () => {
if (lpQueryResult.learningPath?.value?.topics?.length) {
selectCircle(lpQueryResult.learningPath.value.topics[0].circles[0]);
}
});
</script>
<template>
<CockpitProfileContent>
<template #side>
<div
v-for="topic in lpQueryResult.learningPath?.value?.topics ?? []"
:key="topic.id"
class="mb-4"
>
<h4 class="mb-1 font-semibold text-gray-800">
{{ topic.title }}
</h4>
<button
v-for="circle in topic.circles"
:key="circle.id"
class="flex w-full items-center space-x-2 p-2 pr-4 hover:bg-gray-200 lg:pr-8"
:class="{ 'bg-gray-200': selectedCircle === circle }"
@click="selectCircle(circle)"
>
<LearningPathCircle
:sectors="calculateCircleSectorData(circle)"
class="h-10 w-10 snap-center rounded-full bg-white p-0.5"
></LearningPathCircle>
<span>{{ circle.title }}</span>
</button>
</div>
</template>
<template #main>
<ol v-if="selectedCircle" class="flex-auto bg-gray-200 px-6 py-4 lg:px-16">
<li
v-for="learningSequence in selectedCircle.learning_sequences ?? []"
:key="learningSequence.id"
>
<LearningSequence
:course-slug="props.courseSlug"
:circle="selectedCircle"
:learning-sequence="learningSequence"
readonly
hide-links
></LearningSequence>
</li>
</ol>
</template>
</CockpitProfileContent>
</template>

View File

@ -32,6 +32,7 @@ type Props = {
learningSequence: LearningSequence; learningSequence: LearningSequence;
circle: CircleType; circle: CircleType;
readonly?: boolean; readonly?: boolean;
hideLinks?: boolean;
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@ -229,7 +230,7 @@ function checkboxIconUncheckedTailwindClass(lc: LearningContent) {
</div> </div>
</div> </div>
<div <div
v-if="belongsToCompetenceCertificate(learningContent)" v-if="belongsToCompetenceCertificate(learningContent) && !hideLinks"
class="ml-16 text-sm text-gray-800" class="ml-16 text-sm text-gray-800"
> >
{{ {{
@ -298,11 +299,7 @@ function checkboxIconUncheckedTailwindClass(lc: LearningContent) {
<div>{{ $t("a.Selbsteinschätzung") }}</div> <div>{{ $t("a.Selbsteinschätzung") }}</div>
</div> </div>
</div> </div>
<!-- <hr v-if="!learningUnit.last" class="-mx-4 text-gray-500" />-->
</li> </li>
</ol> </ol>
</div> </div>
</template> </template>
<style scoped></style>

View File

@ -64,8 +64,8 @@ function handleFinishedLearningContent() {
props.learningContent, props.learningContent,
props.circle, props.circle,
previousRoute, previousRoute,
(lc: LearningContentWithCompletion) => { async (lc: LearningContentWithCompletion) => {
courseCompletionData.markCompletion(lc, "SUCCESS"); await courseCompletionData.markCompletion(lc, "SUCCESS");
} }
); );
} }

View File

@ -18,9 +18,9 @@ import { useRouteQuery } from "@vueuse/router";
import * as log from "loglevel"; import * as log from "loglevel";
import { computed, onMounted, ref, watchEffect } from "vue"; import { computed, onMounted, ref, watchEffect } from "vue";
import { useTranslation } from "i18next-vue"; import { useTranslation } from "i18next-vue";
import { bustItGetCache } from "@/fetchHelpers";
import { learningContentTypeData } from "@/utils/typeMaps"; import { learningContentTypeData } from "@/utils/typeMaps";
import EvaluationSummary from "@/pages/cockpit/assignmentEvaluationPage/EvaluationSummary.vue"; import EvaluationSummary from "@/pages/cockpit/assignmentEvaluationPage/EvaluationSummary.vue";
import { bustItGetCache } from "@/fetchHelpers";
const { t } = useTranslation(); const { t } = useTranslation();
const courseSession = useCurrentCourseSession(); const courseSession = useCurrentCourseSession();

View File

@ -6,6 +6,7 @@ import { useUserStore } from "@/stores/user";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useTranslation } from "i18next-vue"; import { useTranslation } from "i18next-vue";
import { profileNextRoute, useEntities } from "@/services/onboarding"; import { profileNextRoute, useEntities } from "@/services/onboarding";
import AvatarImage from "@/components/ui/AvatarImage.vue";
const { t } = useTranslation(); const { t } = useTranslation();
@ -36,17 +37,23 @@ const validOrganisation = computed(() => {
return selectedOrganisation.value.id !== 0; return selectedOrganisation.value.id !== 0;
}); });
/* TODO: We do this later (not in the first release) const avatarError = ref(false);
const { const avatarLoading = ref(false);
upload: avatarUpload,
loading: avatarLoading,
error: avatarError,
fileInfo: avatarFileInfo,
} = useFileUpload();
watch(avatarFileInfo, (info) => { async function avatarUpload(e: Event) {
console.log("fileInfo changed", info); const { files } = e.target as HTMLInputElement;
})*/ if (!files?.length) return;
avatarLoading.value = true;
avatarError.value = false;
try {
await user.setUserAvatar(files[0]);
} catch (e) {
avatarError.value = true;
} finally {
avatarLoading.value = false;
}
}
watch(selectedOrganisation, async (organisation) => { watch(selectedOrganisation, async (organisation) => {
await user.setUserOrganisation(organisation.id); await user.setUserOrganisation(organisation.id);
@ -74,7 +81,6 @@ const nextRoute = computed(() => {
<ItDropdownSelect v-model="selectedOrganisation" :items="organisations" /> <ItDropdownSelect v-model="selectedOrganisation" :items="organisations" />
<!--- TODO: We do this later (not in the first release)
<div class="mt-16 flex flex-col justify-between gap-12 lg:flex-row lg:gap-24"> <div class="mt-16 flex flex-col justify-between gap-12 lg:flex-row lg:gap-24">
<div> <div>
<h3 class="mb-3">{{ $t("a.Profilbild") }}</h3> <h3 class="mb-3">{{ $t("a.Profilbild") }}</h3>
@ -103,7 +109,6 @@ const nextRoute = computed(() => {
</div> </div>
<AvatarImage :loading="avatarLoading" :image-url="user.avatar_url" /> <AvatarImage :loading="avatarLoading" :image-url="user.avatar_url" />
</div> </div>
-->
</template> </template>
<template #footer> <template #footer>

View File

@ -243,9 +243,19 @@ const router = createRouter({
}, },
{ {
path: "profile/:userId", path: "profile/:userId",
component: () => import("@/pages/cockpit/CockpitUserProfilePage.vue"), component: () =>
import("@/pages/cockpit/profilePage/CockpitUserProfilePage.vue"),
props: true, props: true,
name: "cockpitUserProfile", name: "cockpitUserProfile",
children: [
{
path: "learning-path",
component: () =>
import("@/pages/cockpit/profilePage/LearningPathProfilePage.vue"),
props: true,
name: "cockpitProfileLearningPath",
},
],
}, },
{ {
path: "profile/:userId/:circleSlug", path: "profile/:userId/:circleSlug",

View File

@ -24,27 +24,30 @@ export async function uploadFile(fileData: FileData, file: File) {
if (fileData.fields) { if (fileData.fields) {
return s3Upload(fileData, file); return s3Upload(fileData, file);
} else { } else {
return directUpload(fileData, file); return directUpload(fileData.url, file);
} }
} }
function directUpload(fileData: FileData, file: File) { export function directUpload(url: string, file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const headers = { const headers: HeadersInit = {
Accept: "application/json", Accept: "application/json",
} as HeadersInit; };
const csrfToken = getCookieValue("csrftoken");
if (csrfToken) {
headers["X-CSRFToken"] = csrfToken;
}
const options = { const options = {
method: "POST", method: "POST",
headers: headers, headers: headers,
body: formData, body: formData,
}; };
// @ts-ignore
options.headers["X-CSRFToken"] = getCookieValue("csrftoken");
return handleUpload(fileData.url, options); return handleUpload(url, options);
} }
function s3Upload(fileData: FileData, file: File) { function s3Upload(fileData: FileData, file: File) {

View File

@ -1,4 +1,4 @@
import { itGetCached } from "@/fetchHelpers"; import { itGet } from "@/fetchHelpers";
import type { Ref } from "vue"; import type { Ref } from "vue";
import { ref, watchEffect } from "vue"; import { ref, watchEffect } from "vue";
@ -27,6 +27,7 @@ interface Completion {
status: CompletionStatus; status: CompletionStatus;
user_id: string; user_id: string;
last_name: string; last_name: string;
url: string;
} }
export interface Assignment { export interface Assignment {
@ -73,7 +74,7 @@ export const useMentorCockpit = (
summary.value = null; summary.value = null;
error.value = null; error.value = null;
itGetCached(`/api/mentor/${courseSessionId}/summary`) itGet(`/api/mentor/${courseSessionId}/summary`)
.then((response) => { .then((response) => {
summary.value = response; summary.value = response;
}) })
@ -92,6 +93,7 @@ export const useMentorCockpit = (
summary, summary,
error, error,
getCircleTitleById, getCircleTitleById,
fetchData,
getAssignmentById, getAssignmentById,
}; };
}; };

View File

@ -73,16 +73,18 @@ export const useCircleStore = defineStore({
}); });
} }
}, },
continueFromLearningContent( async continueFromLearningContent(
currentLearningContent: LearningContentWithCompletion, currentLearningContent: LearningContentWithCompletion,
circle: CircleType, circle: CircleType,
returnRoute?: RouteLocationNormalized, returnRoute?: RouteLocationNormalized,
markCompletionFn?: (learningContent: LearningContentWithCompletion) => void markCompletionFn?: (
learningContent: LearningContentWithCompletion
) => Promise<void>
) { ) {
if (currentLearningContent) { if (currentLearningContent) {
if (currentLearningContent.can_user_self_toggle_course_completion) { if (currentLearningContent.can_user_self_toggle_course_completion) {
if (markCompletionFn) { if (markCompletionFn) {
markCompletionFn(currentLearningContent); await markCompletionFn(currentLearningContent);
} }
} }
this.closeLearningContent(currentLearningContent, circle, returnRoute); this.closeLearningContent(currentLearningContent, circle, returnRoute);

View File

@ -2,6 +2,7 @@ import log from "loglevel";
import { bustItGetCache, itGetCached, itPost } from "@/fetchHelpers"; import { bustItGetCache, itGetCached, itPost } from "@/fetchHelpers";
import { setI18nLanguage } from "@/i18nextWrapper"; import { setI18nLanguage } from "@/i18nextWrapper";
import { directUpload } from "@/services/files";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
@ -150,5 +151,9 @@ export const useUserStore = defineStore({
this.$state.organisation = organisation; this.$state.organisation = organisation;
await itPost("/api/core/me/", { organisation }, { method: "PUT" }); await itPost("/api/core/me/", { organisation }, { method: "PUT" });
}, },
async setUserAvatar(file: File) {
const r = await directUpload("/api/core/avatar/", file);
this.$state.avatar_url = r.url;
},
}, },
}); });

View File

@ -21,5 +21,8 @@ fi
# sed -i "s|command=/usr/local/bin/supercronic /app/supercronic_crontab|command=/usr/local/bin/supercronic /app/supercronic_crontab -sentry-dsn '$IT_SENTRY_DSN'|" /app/supervisord.conf # sed -i "s|command=/usr/local/bin/supercronic /app/supercronic_crontab|command=/usr/local/bin/supercronic /app/supercronic_crontab -sentry-dsn '$IT_SENTRY_DSN'|" /app/supervisord.conf
#fi #fi
# Create Prüfungslehrgang
python /app/manage.py create_vermittler_pruefung
# Set the command to run supervisord # Set the command to run supervisord
/home/django/.local/bin/supervisord -c /app/supervisord.conf /home/django/.local/bin/supervisord -c /app/supervisord.conf

View File

@ -218,7 +218,10 @@ describe("assignmentTrainer.cy.js", () => {
cy.get('[data-cy="title"]').should("contain", "Feedback"); cy.get('[data-cy="title"]').should("contain", "Feedback");
cy.get('[data-cy="evaluation-duedate]"').should("not.exist"); cy.get('[data-cy="evaluation-duedate]"').should("not.exist");
cy.get('[data-cy="instruction"]').should("contain", "Intro für Feedback"); cy.get('[data-cy="instruction"]').should(
"contain",
"Bitte unterstütze Test Student1 und gib Feedback zum Auftrag."
);
cy.get('[data-cy="start-evaluation"]').click(); cy.get('[data-cy="start-evaluation"]').click();
cy.get('[data-cy="evaluation-task"]').should("contain", "Feedback 1 / 5"); cy.get('[data-cy="evaluation-task"]').should("contain", "Feedback 1 / 5");

View File

@ -9,71 +9,71 @@ describe("circle.cy.js", () => {
}); });
it("can open circle page", () => { it("can open circle page", () => {
cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug"); cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
}); });
it("can toggle learning content", () => { it("can toggle learning content", () => {
cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug"); cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
cy.get( cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]' "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
).should("have.class", "cy-unchecked"); ).should("have.class", "cy-unchecked");
cy.get( cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]' "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
).click(); ).click();
cy.get( cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]' "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
).should("have.class", "cy-checked"); ).should("have.class", "cy-checked");
// completion data should still be there after reload // completion data should still be there after reload
cy.reload(); cy.reload();
cy.get( cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]' "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
).should("have.class", "cy-checked"); ).should("have.class", "cy-checked");
}); });
it("can open learning contents and complete them by continuing", () => { it("can open learning contents and complete them by continuing", () => {
cy.get( cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick"]' "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick\"]"
).click(); ).click();
cy.get('[data-cy="lc-title"]').should( cy.get("[data-cy=\"lc-title\"]").should(
"contain", "contain",
"Verschaffe dir einen Überblick" "Verschaffe dir einen Überblick"
); );
cy.get('[data-cy="complete-and-continue"]').click({ force: true }); cy.get("[data-cy=\"complete-and-continue\"]").click({ force: true });
cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug"); cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
cy.get('[data-cy="ls-continue-button"]').click(); cy.get("[data-cy=\"ls-continue-button\"]").click({ force: true });
cy.get('[data-cy="lc-title"]').should( cy.get("[data-cy=\"lc-title\"]").should(
"contain", "contain",
"Handlungsfeld «Fahrzeug»" "Handlungsfeld «Fahrzeug»"
); );
cy.get('[data-cy="complete-and-continue"]').click({ force: true }); cy.get("[data-cy=\"complete-and-continue\"]").click({ force: true });
cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug"); cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
cy.get( cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick-checkbox"]' "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick-checkbox\"]"
).should("have.class", "cy-checked"); ).should("have.class", "cy-checked");
cy.get( cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]' "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]"
).should("have.class", "cy-checked"); ).should("have.class", "cy-checked");
}); });
it("continue button works", () => { it("continue button works", () => {
cy.get('[data-cy="ls-continue-button"]').should("contain", "Los geht's"); cy.get("[data-cy=\"ls-continue-button\"]").should("contain", "Los geht's");
cy.get('[data-cy="ls-continue-button"]').click(); cy.get("[data-cy=\"ls-continue-button\"]").click();
cy.get('[data-cy="lc-title"]').should( cy.get("[data-cy=\"lc-title\"]").should(
"contain", "contain",
"Verschaffe dir einen Überblick" "Verschaffe dir einen Überblick"
); );
cy.get('[data-cy="complete-and-continue"]').click({ force: true }); cy.get("[data-cy=\"complete-and-continue\"]").click({ force: true });
cy.get('[data-cy="ls-continue-button"]').should("contain", "Weiter geht's"); cy.get("[data-cy=\"ls-continue-button\"]").should("contain", "Weiter geht's");
cy.get('[data-cy="ls-continue-button"]').click(); cy.get("[data-cy=\"ls-continue-button\"]").click();
cy.get('[data-cy="lc-title"]').should( cy.get("[data-cy=\"lc-title\"]").should(
"contain", "contain",
"Handlungsfeld «Fahrzeug»" "Handlungsfeld «Fahrzeug»"
); );
@ -81,43 +81,43 @@ describe("circle.cy.js", () => {
it("can open learning content by url", () => { it("can open learning content by url", () => {
cy.visit("/course/test-lehrgang/learn/fahrzeug/handlungsfeld-fahrzeug"); cy.visit("/course/test-lehrgang/learn/fahrzeug/handlungsfeld-fahrzeug");
cy.get('[data-cy="lc-title"]').should( cy.get("[data-cy=\"lc-title\"]").should(
"contain", "contain",
"Handlungsfeld «Fahrzeug»" "Handlungsfeld «Fahrzeug»"
); );
cy.get('[data-cy="close-learning-content"]').click(); cy.get("[data-cy=\"close-learning-content\"]").click();
cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug"); cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug");
}); });
it("checks number of sequences and contents", () => { it("checks number of sequences and contents", () => {
cy.get('[data-cy="lp-learning-sequence"]').should("have.length", 3); cy.get("[data-cy=\"lp-learning-sequence\"]").should("have.length", 3);
cy.get('[data-cy="lp-learning-sequence"]') cy.get("[data-cy=\"lp-learning-sequence\"]")
.first() .first()
.should("contain", "Vorbereitung"); .should("contain", "Vorbereitung");
cy.get('[data-cy="lp-learning-sequence"]') cy.get("[data-cy=\"lp-learning-sequence\"]")
.eq(1) .eq(1)
.should("contain", "Training"); .should("contain", "Training");
cy.get('[data-cy="lp-learning-sequence"]') cy.get("[data-cy=\"lp-learning-sequence\"]")
.last() .last()
.should("contain", "Transfer"); .should("contain", "Transfer");
cy.get('[data-cy="lp-learning-content"]').should("have.length", 10); cy.get("[data-cy=\"lp-learning-content\"]").should("have.length", 10);
cy.get('[data-cy="lp-learning-content"]') cy.get("[data-cy=\"lp-learning-content\"]")
.first() .first()
.should("contain", "Verschaffe dir einen Überblick"); .should("contain", "Verschaffe dir einen Überblick");
cy.get('[data-cy="lp-learning-content"]') cy.get("[data-cy=\"lp-learning-content\"]")
.eq(4) .eq(4)
.should("contain", "Präsenzkurs Fahrzeug"); .should("contain", "Präsenzkurs Fahrzeug");
cy.get('[data-cy="lp-learning-content"]') cy.get("[data-cy=\"lp-learning-content\"]")
.eq(7) .eq(7)
.should("contain", "Reflexion"); .should("contain", "Reflexion");
cy.get('[data-cy="lp-learning-content"]') cy.get("[data-cy=\"lp-learning-content\"]")
.last() .last()
.should("contain", "Feedback"); .should("contain", "Feedback");
cy.visit("/course/test-lehrgang/learn/reisen"); cy.visit("/course/test-lehrgang/learn/reisen");
cy.get('[data-cy="lp-learning-sequence"]').should("have.length", 3); cy.get("[data-cy=\"lp-learning-sequence\"]").should("have.length", 3);
cy.get('[data-cy="lp-learning-content"]').should("have.length", 9); cy.get("[data-cy=\"lp-learning-content\"]").should("have.length", 9);
}); });
}); });

View File

@ -12,7 +12,12 @@ from django_ratelimit.exceptions import Ratelimited
from graphene_django.views import GraphQLView from graphene_django.views import GraphQLView
from vbv_lernwelt.api.directory import list_entities from vbv_lernwelt.api.directory import list_entities
from vbv_lernwelt.api.user import get_cockpit_type, me_user_view from vbv_lernwelt.api.user import (
get_cockpit_type,
get_profile,
me_user_view,
post_avatar,
)
from vbv_lernwelt.assignment.views import request_assignment_completion_status 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.middleware.auth import django_view_authentication_exempt
from vbv_lernwelt.core.schema import schema from vbv_lernwelt.core.schema import schema
@ -54,6 +59,7 @@ from vbv_lernwelt.importer.views import (
coursesessions_trainers_import, coursesessions_trainers_import,
t2l_sync, t2l_sync,
) )
from vbv_lernwelt.media_files.views import user_image
from vbv_lernwelt.notify.views import email_notification_settings from vbv_lernwelt.notify.views import email_notification_settings
from wagtail import urls as wagtail_urls from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls from wagtail.admin import urls as wagtailadmin_urls
@ -99,7 +105,9 @@ urlpatterns = [
# user management # user management
path("sso/", include("vbv_lernwelt.sso.urls")), path("sso/", include("vbv_lernwelt.sso.urls")),
re_path(r'api/core/me/$', me_user_view, name='me_user_view'), re_path(r'api/core/me/$', me_user_view, name='me_user_view'),
re_path(r'api/core/avatar/$', post_avatar, name='post_avatar'),
re_path(r'api/core/entities/$', list_entities, name='list_entities'), re_path(r'api/core/entities/$', list_entities, name='list_entities'),
path(r'api/core/profile/<signed_int:course_session_id>/<uuid:user_id>', get_profile, name='get_profile_view'),
re_path(r'api/core/login/$', django_view_authentication_exempt(vue_login), re_path(r'api/core/login/$', django_view_authentication_exempt(vue_login),
name='vue_login'), name='vue_login'),
@ -143,6 +151,8 @@ urlpatterns = [
name="request_assignment_completion_status"), name="request_assignment_completion_status"),
# documents # documents
path("api/core/userimage/<int:image_id>", user_image, name="user_image"),
# TODO: remfactor to files app # TODO: remfactor to files app
path(r'api/core/document/start/', document_upload_start, path(r'api/core/document/start/', document_upload_start,
name='file_upload_start'), name='file_upload_start'),

View File

@ -0,0 +1,60 @@
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
class ProfileViewTest(APITestCase):
def setUp(self) -> None:
self.course, _ = create_course("Test Course")
self.course_session = create_course_session(
course=self.course, title="Test Session"
)
self.user = create_user("user")
add_course_session_user(
self.course_session,
self.user,
role=CourseSessionUser.Role.MEMBER,
)
self.client.force_login(self.user)
def test_user_profile(self) -> None:
# GIVEN
url = reverse(
"get_profile_view",
kwargs={
"course_session_id": self.course_session.id,
"user_id": self.user.id,
},
)
# WHEN
response = self.client.get(url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
profile = response.data
self.assertEqual(
profile,
{
"id": str(self.user.id),
"first_name": self.user.first_name,
"last_name": self.user.last_name,
"email": self.user.email,
"username": self.user.username,
"avatar_url": "/static/avatars/myvbv-default-avatar.png",
"organisation": None,
"is_superuser": False,
"course_session_experts": [],
"language": "de",
},
)

View File

@ -5,10 +5,11 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from vbv_lernwelt.core.serializers import UserSerializer from vbv_lernwelt.core.serializers import UserSerializer
from vbv_lernwelt.course.models import Course, CourseSessionUser from vbv_lernwelt.course.models import Course, CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.iam.permissions import can_view_profile
from vbv_lernwelt.learning_mentor.models import LearningMentor from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.media_files.models import UserImage
@api_view(["GET", "PUT"]) @api_view(["GET", "PUT"])
@ -59,3 +60,30 @@ def get_cockpit_type(request, course_id: int):
cockpit_type = None cockpit_type = None
return Response({"type": cockpit_type}) return Response({"type": cockpit_type})
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def get_profile(request, course_session_id: int, user_id: str):
course_session_user = get_object_or_404(
CourseSessionUser, course_session_id=course_session_id, user_id=user_id
)
if not can_view_profile(request.user, course_session_user):
return Response(status=403)
return Response(UserSerializer(course_session_user.user).data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def post_avatar(request):
if "file" not in request.FILES:
return Response(status=400)
request.user.avatar = UserImage.objects.create(
file=request.FILES["file"],
)
request.user.save()
return Response({"url": request.user.avatar_url})

View File

@ -99,7 +99,11 @@ class AssignmentCompletionMutation(graphene.Mutation):
AssignmentCompletionStatus.EVALUATION_SUBMITTED, AssignmentCompletionStatus.EVALUATION_SUBMITTED,
AssignmentCompletionStatus.EVALUATION_IN_PROGRESS, AssignmentCompletionStatus.EVALUATION_IN_PROGRESS,
): ):
if not can_evaluate_assignments(info.context.user, course_session_id): if not can_evaluate_assignments(
evaluation_user=info.context.user,
assignment_user_id=assignment_user_id,
course_session_id=course_session_id,
):
raise PermissionDenied() raise PermissionDenied()
evaluation_data = { evaluation_data = {

View File

@ -109,7 +109,9 @@ def resolve_assignment_completion(
assignment_user_id = info.context.user.id assignment_user_id = info.context.user.id
if str(assignment_user_id) == str(info.context.user.id) or can_evaluate_assignments( if str(assignment_user_id) == str(info.context.user.id) or can_evaluate_assignments(
info.context.user, course_session_id evaluation_user=info.context.user,
assignment_user_id=assignment_user_id,
course_session_id=course_session_id,
): ):
course_id = CourseSession.objects.get(id=course_session_id).course_id course_id = CourseSession.objects.get(id=course_session_id).course_id
if has_course_access(info.context.user, course_id): if has_course_access(info.context.user, course_id):

View File

@ -43,7 +43,7 @@ class UserAdmin(auth_admin.UserAdmin):
}, },
), ),
(_("Important dates"), {"fields": ("last_login", "date_joined")}), (_("Important dates"), {"fields": ("last_login", "date_joined")}),
(_("Profile"), {"fields": ("organisation", "language")}), (_("Profile"), {"fields": ("organisation", "language", "avatar")}),
(_("Additional data"), {"fields": ("additional_json_data",)}), (_("Additional data"), {"fields": ("additional_json_data",)}),
) )
list_display = [ list_display = [

View File

@ -1,7 +1,11 @@
from django.conf import settings
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Group, Permission from django.contrib.auth.models import Group, Permission
from django.core.files import File
from environs import Env from environs import Env
from vbv_lernwelt.media_files.models import UserImage
env = Env() env = Env()
env.read_env() env.read_env()
@ -24,52 +28,54 @@ default_users = [
"email": "student", "email": "student",
"first_name": "Student", "first_name": "Student",
"last_name": "Meier", "last_name": "Meier",
"avatar_url": "/static/avatars/avatar_iterativ.png", "avatar_image": "avatar_iterativ.png",
}, },
{ {
"email": "daniel.egger@iterativ.ch", "email": "daniel.egger@iterativ.ch",
"first_name": "Daniel", "first_name": "Daniel",
"last_name": "Egger", "last_name": "Egger",
"avatar_url": "/static/avatars/avatar_iterativ.png", "avatar_image": "avatar_iterativ.png",
}, },
{ {
"email": "axel.manderbach@lernetz.ch", "email": "axel.manderbach@lernetz.ch",
"first_name": "Axel", "first_name": "Axel",
"last_name": "Manderbach", "last_name": "Manderbach",
"avatar_url": "/static/avatars/avatar_axel.jpg", "avatar_image": "avatar_axel.jpg",
}, },
{ {
"email": "christoph.bosshard@vbv-afa.ch", "email": "christoph.bosshard@vbv-afa.ch",
"first_name": "Christoph", "first_name": "Christoph",
"last_name": "Bosshard", "last_name": "Bosshard",
"avatar_url": "/static/avatars/avatar_christoph.png", "avatar_image": "avatar_christoph.png",
"password": "myvbv1234", "password": "myvbv1234",
}, },
{ {
"email": "alexandra.vangelista@lernetz.ch", "email": "alexandra.vangelista@lernetz.ch",
"first_name": "Alexandra", "first_name": "Alexandra",
"last_name": "Vangelista", "last_name": "Vangelista",
"avatar_url": "/static/avatars/avatar_alexandra.png", "avatar_image": "avatar_alexandra.png",
"password": "myvbv1234", "password": "myvbv1234",
}, },
{ {
"email": "chantal.rosenberg@vbv-afa.ch", "email": "chantal.rosenberg@vbv-afa.ch",
"first_name": "Chantal", "first_name": "Chantal",
"last_name": "Rosenberg", "last_name": "Rosenberg",
"avatar_url": "/static/avatars/avatar_chantal.png", "avatar_image": "avatar_chantal.png",
"password": "myvbv1234", "password": "myvbv1234",
}, },
{ {
"email": "bianca.muster@eiger-versicherungen.ch", "email": "bianca.muster@eiger-versicherungen.ch",
"first_name": "Bianca", "first_name": "Bianca",
"last_name": "Muster", "last_name": "Muster",
"avatar_url": "/static/avatars/avatar_bianca.png", "avatar_image": "avatar_bianca.png",
"password": "myvbv1234", "password": "myvbv1234",
}, },
] ]
AVATAR_DIR = settings.APPS_DIR / "static" / "avatars"
def create_default_users(default_password="test"):
def create_default_users(default_password="test", set_avatar=False):
admin_group, created = Group.objects.get_or_create(name="admin_group") admin_group, created = Group.objects.get_or_create(name="admin_group")
_content_creator_grop, _created = Group.objects.get_or_create( _content_creator_grop, _created = Group.objects.get_or_create(
name="content_creator_grop" name="content_creator_grop"
@ -81,9 +87,9 @@ def create_default_users(default_password="test"):
email, email,
first_name, first_name,
last_name, last_name,
avatar_url,
language, language,
password, password,
avatar_image: str = None,
): ):
user, _ = User.objects.get_or_create( user, _ = User.objects.get_or_create(
id=_id, id=_id,
@ -92,25 +98,33 @@ def create_default_users(default_password="test"):
language=language, language=language,
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
avatar_url=avatar_url,
password=make_password(password), password=make_password(password),
) )
if avatar_image and set_avatar:
with open(AVATAR_DIR / avatar_image, "rb") as f:
image, _ = UserImage.objects.get_or_create(
file=File(f),
)
user.avatar = image
user.save()
return user return user
def _create_student_user( def _create_student_user(
email, email,
first_name, first_name,
last_name, last_name,
avatar_url="",
password=default_password, password=default_password,
language="de", language="de",
avatar_image=None,
id=None, id=None,
): ):
student_user = _create_user( student_user = _create_user(
email=email, email=email,
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
avatar_url=avatar_url, avatar_image=avatar_image,
language=language, language=language,
password=password, password=password,
_id=id, _id=id,
@ -120,13 +134,18 @@ def create_default_users(default_password="test"):
student_user.save() student_user.save()
def _create_admin_user( def _create_admin_user(
email, first_name, last_name, avatar_url="", id=None, password=default_password email,
first_name,
last_name,
avatar_image=None,
id=None,
password=default_password,
): ):
admin_user = _create_user( admin_user = _create_user(
email=email, email=email,
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
avatar_url=avatar_url, avatar_image=avatar_image,
password=password, password=password,
language="de", language="de",
_id=id, _id=id,
@ -145,7 +164,7 @@ def create_default_users(default_password="test"):
email=email, email=email,
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
avatar_url="", avatar_image=None,
language="de", language="de",
password=password, password=password,
) )
@ -158,7 +177,7 @@ def create_default_users(default_password="test"):
email="info@iterativ.ch", email="info@iterativ.ch",
first_name="Info", first_name="Info",
last_name="Iterativ", last_name="Iterativ",
avatar_url="/static/avatars/avatar_iterativ.png", avatar_image="avatar_iterativ.png",
password=env("IT_DEFAULT_ADMIN_PASSWORD", default_password), password=env("IT_DEFAULT_ADMIN_PASSWORD", default_password),
) )
@ -166,7 +185,7 @@ def create_default_users(default_password="test"):
email="admin", email="admin",
first_name="Peter", first_name="Peter",
last_name="Adminson", last_name="Adminson",
avatar_url="/static/avatars/avatar_iterativ.png", avatar_image="avatar_iterativ.png",
id=ADMIN_USER_ID, id=ADMIN_USER_ID,
password=env("IT_DEFAULT_ADMIN_PASSWORD", default_password), password=env("IT_DEFAULT_ADMIN_PASSWORD", default_password),
) )
@ -183,7 +202,7 @@ def create_default_users(default_password="test"):
email="expert-vv.expert2@eiger-versicherungen.ch", email="expert-vv.expert2@eiger-versicherungen.ch",
first_name="Christa", first_name="Christa",
last_name="von Allmen", last_name="von Allmen",
avatar_url="/static/avatars/uk1.patrizia.huggel.jpg", avatar_image="uk1.patrizia.huggel.jpg",
) )
_create_student_user( _create_student_user(
email="expert-vv.expert3@eiger-versicherungen.ch", email="expert-vv.expert3@eiger-versicherungen.ch",
@ -199,53 +218,53 @@ def create_default_users(default_password="test"):
email="patrizia.huggel@eiger-versicherungen.ch", email="patrizia.huggel@eiger-versicherungen.ch",
first_name="Patrizia", first_name="Patrizia",
last_name="Huggel", last_name="Huggel",
avatar_url="/static/avatars/uk1.patrizia.huggel.jpg", avatar_image="uk1.patrizia.huggel.jpg",
password="myvbv1234", password="myvbv1234",
) )
_create_student_user( _create_student_user(
email="andreas.feuz@eiger-versicherungen.ch", email="andreas.feuz@eiger-versicherungen.ch",
first_name="Andreas", first_name="Andreas",
last_name="Feuz", last_name="Feuz",
avatar_url="/static/avatars/uk1.daniel.tanaka.jpg", avatar_image="uk1.daniel.tanaka.jpg",
password="myvbv1234", password="myvbv1234",
) )
_create_student_user( _create_student_user(
email="daniel.tanaka@eiger-versicherung.ch", email="daniel.tanaka@eiger-versicherung.ch",
first_name="Daniel", first_name="Daniel",
last_name="Tanaka", last_name="Tanaka",
avatar_url="/static/avatars/uk1.daniel.tanaka.jpg", avatar_image="uk1.daniel.tanaka.jpg",
) )
_create_student_user( _create_student_user(
email="maria.spini@eiger-versicherung.ch", email="maria.spini@eiger-versicherung.ch",
first_name="Maria", first_name="Maria",
last_name="Spini", last_name="Spini",
avatar_url="/static/avatars/uk1.maria.spini.jpg", avatar_image="uk1.maria.spini.jpg",
) )
_create_student_user( _create_student_user(
email="christian.koller@eiger-versicherung.ch", email="christian.koller@eiger-versicherung.ch",
first_name="Christian", first_name="Christian",
last_name="Koller", last_name="Koller",
avatar_url="/static/avatars/uk1.christian.koller.jpg", avatar_image="uk1.christian.koller.jpg",
) )
_create_student_user( _create_student_user(
email="michael.meier@example.com", email="michael.meier@example.com",
first_name="Michael", first_name="Michael",
last_name="Meier", last_name="Meier",
avatar_url="/static/avatars/uk1.michael.meier.jpg", avatar_image="uk1.michael.meier.jpg",
password="myvbv1234", password="myvbv1234",
) )
_create_student_user( _create_student_user(
email="lina.egger@example.com", email="lina.egger@example.com",
first_name="Lina", first_name="Lina",
last_name="Egger", last_name="Egger",
avatar_url="/static/avatars/uk1.lina.egger.jpg", avatar_image="uk1.lina.egger.jpg",
password="myvbv1234", password="myvbv1234",
) )
_create_student_user( _create_student_user(
email="evelyn.schmid@example.com", email="evelyn.schmid@example.com",
first_name="Evelyn", first_name="Evelyn",
last_name="Schmid", last_name="Schmid",
avatar_url="/static/avatars/uk1.evelyn.schmid.jpg", avatar_image="uk1.evelyn.schmid.jpg",
) )
_create_student_user( _create_student_user(
@ -272,7 +291,7 @@ def create_default_users(default_password="test"):
email="luca.dupont@assurance.ch", email="luca.dupont@assurance.ch",
first_name="Luca", first_name="Luca",
last_name="Dupont", last_name="Dupont",
avatar_url="/static/avatars/uk1.michael.meier.jpg", avatar_image="uk1.michael.meier.jpg",
password="myafa1234", password="myafa1234",
language="fr", language="fr",
) )
@ -280,7 +299,7 @@ def create_default_users(default_password="test"):
email="patrick.muster@eiger-versicherungen.ch", email="patrick.muster@eiger-versicherungen.ch",
first_name="Patrick", first_name="Patrick",
last_name="Muster", last_name="Muster",
avatar_url="/static/avatars/uk1.michael.meier.jpg", avatar_image="uk1.michael.meier.jpg",
password="myvbv1234", password="myvbv1234",
language="de", language="de",
) )
@ -288,7 +307,7 @@ def create_default_users(default_password="test"):
email="geraldine.kolly@assurance.ch", email="geraldine.kolly@assurance.ch",
first_name="Géraldine", first_name="Géraldine",
last_name="Kolly", last_name="Kolly",
avatar_url="/static/avatars/uk1.patrizia.huggel.jpg", avatar_image="uk1.patrizia.huggel.jpg",
password="myafa1234", password="myafa1234",
language="fr", language="fr",
) )
@ -299,35 +318,35 @@ def create_default_users(default_password="test"):
email="test-trainer1@example.com", email="test-trainer1@example.com",
first_name="Test", first_name="Test",
last_name="Trainer1", last_name="Trainer1",
avatar_url="/static/avatars/uk1.patrizia.huggel.jpg", avatar_image="uk1.patrizia.huggel.jpg",
) )
_create_student_user( _create_student_user(
id=TEST_TRAINER2_USER_ID, id=TEST_TRAINER2_USER_ID,
email="test-trainer2@example.com", email="test-trainer2@example.com",
first_name="Test", first_name="Test",
last_name="Trainer2", last_name="Trainer2",
avatar_url="/static/avatars/uk1.christian.koller.jpg", avatar_image="uk1.christian.koller.jpg",
) )
_create_student_user( _create_student_user(
id=TEST_STUDENT1_USER_ID, id=TEST_STUDENT1_USER_ID,
email="test-student1@example.com", email="test-student1@example.com",
first_name="Test", first_name="Test",
last_name="Student1", last_name="Student1",
avatar_url="/static/avatars/uk1.michael.meier.jpg", avatar_image="uk1.michael.meier.jpg",
) )
_create_student_user( _create_student_user(
id=TEST_STUDENT2_USER_ID, id=TEST_STUDENT2_USER_ID,
email="test-student2@example.com", email="test-student2@example.com",
first_name="Test", first_name="Test",
last_name="Student2", last_name="Student2",
avatar_url="/static/avatars/uk1.lina.egger.jpg", avatar_image="uk1.lina.egger.jpg",
) )
_create_student_user( _create_student_user(
id=TEST_STUDENT3_USER_ID, id=TEST_STUDENT3_USER_ID,
email="test-student3@example.com", email="test-student3@example.com",
first_name="Test", first_name="Test",
last_name="Student3", last_name="Student3",
avatar_url="/static/avatars/uk1.christian.koller.jpg", avatar_image="uk1.christian.koller.jpg",
) )
_create_staff_user( _create_staff_user(
email="matthias.wirth@vbv-afa.ch", email="matthias.wirth@vbv-afa.ch",
@ -341,7 +360,6 @@ def create_default_users(default_password="test"):
last_name="Regionalleiter", last_name="Regionalleiter",
password=default_password, password=default_password,
language="de", language="de",
avatar_url="",
) )
_create_user( _create_user(
_id=TEST_MENTOR1_USER_ID, _id=TEST_MENTOR1_USER_ID,
@ -350,7 +368,7 @@ def create_default_users(default_password="test"):
last_name="Weber-Mentor", last_name="Weber-Mentor",
password=default_password, password=default_password,
language="de", language="de",
avatar_url="/static/avatars/uk1.patrizia.huggel.jpg", avatar_image="uk1.patrizia.huggel.jpg",
) )

View File

@ -1,5 +1,6 @@
import json import json
from graphene import String
from graphene.types.generic import GenericScalar from graphene.types.generic import GenericScalar
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
@ -7,6 +8,8 @@ from vbv_lernwelt.core.models import User
class UserObjectType(DjangoObjectType): class UserObjectType(DjangoObjectType):
avatar_url = String(source="avatar_url")
class Meta: class Meta:
model = User model = User
fields = ( fields = (

View File

@ -6,4 +6,4 @@ from vbv_lernwelt.core.create_default_users import create_default_users
@click.command() @click.command()
def command(): def command():
print("Creating default users.") print("Creating default users.")
create_default_users() create_default_users(set_avatar=True)

View File

@ -0,0 +1,30 @@
import django.db.models.deletion
from django.db import migrations, models
from vbv_lernwelt.core.model_utils import migrate_avatars
class Migration(migrations.Migration):
dependencies = [
("media_files", "0001_initial"),
("core", "0003_organisations"),
]
operations = [
migrations.AddField(
model_name="user",
name="avatar",
field=models.ForeignKey(
blank=True,
help_text="Avatar image for the user",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="media_files.userimage",
),
),
migrations.RunPython(migrate_avatars),
migrations.RemoveField(
model_name="user",
name="avatar_url",
),
]

View File

@ -1,3 +1,5 @@
from django.conf import settings
from django.core.files import File
from wagtail.models import Page from wagtail.models import Page
@ -144,3 +146,39 @@ def remove_organisations(apps=None, schema_editor=None):
Organisation.objects.filter( Organisation.objects.filter(
organisation_id=org_id, organisation_id=org_id,
).delete() ).delete()
def migrate_avatars(apps=None, schema_editor=None):
# pylint: disable=import-outside-toplevel
if apps is None:
from vbv_lernwelt.core.models import User
from vbv_lernwelt.media_files.models import UserImage
else:
User = apps.get_model("core", "User")
UserImage = apps.get_model("media_files", "UserImage")
# Models created by Django migration don't contain methods of the parent model.
# We need to add them manually.
from wagtail.images.models import AbstractImage
UserImage.get_upload_to = AbstractImage.get_upload_to
avatar_dir = settings.APPS_DIR / "static" / "avatars"
for user in User.objects.all().exclude(
avatar_url="/static/avatars/myvbv-default-avatar.png"
):
if not user.avatar_url:
continue
avatar_file = user.avatar_url.split("/")[-1]
try:
with open(avatar_dir / avatar_file, "rb") as f:
image = UserImage.objects.create(
file=File(f),
)
user.avatar = image
user.save()
except FileNotFoundError:
pass

View File

@ -3,6 +3,7 @@ import uuid
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.db.models import JSONField from django.db.models import JSONField
from django.urls import reverse
class Organisation(models.Model): class Organisation(models.Model):
@ -34,9 +35,14 @@ class User(AbstractUser):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
avatar_url = models.CharField( avatar = models.ForeignKey(
max_length=254, blank=True, default="/static/avatars/myvbv-default-avatar.png" "media_files.UserImage",
null=True,
blank=True,
on_delete=models.SET_NULL,
help_text="Avatar image for the user",
) )
email = models.EmailField("email address", unique=True) email = models.EmailField("email address", unique=True)
sso_id = models.UUIDField( sso_id = models.UUIDField(
"SSO subscriber ID", unique=True, null=True, blank=True, default=None "SSO subscriber ID", unique=True, null=True, blank=True, default=None
@ -48,6 +54,16 @@ class User(AbstractUser):
Organisation, on_delete=models.SET_NULL, null=True, blank=True Organisation, on_delete=models.SET_NULL, null=True, blank=True
) )
@property
def avatar_url(self):
if self.avatar:
filter_spec = "fill-400x400"
self.avatar.get_rendition(filter_spec)
url = reverse("user_image", kwargs={"image_id": self.avatar.id})
return f"{url}?filter={filter_spec}"
else:
return "/static/avatars/myvbv-default-avatar.png"
class SecurityRequestResponseLog(models.Model): class SecurityRequestResponseLog(models.Model):
label = models.CharField(max_length=255, blank=True, default="") label = models.CharField(max_length=255, blank=True, default="")

View File

@ -8,3 +8,4 @@ COURSE_UK_IT = -8
COURSE_UK_TRAINING_IT = -9 COURSE_UK_TRAINING_IT = -9
COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID = -10 COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID = -10
COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID = -11 COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID = -11
COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID = -12

View File

@ -1,5 +1,6 @@
from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN_ID from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN_ID
from vbv_lernwelt.course.factories import CoursePageFactory from vbv_lernwelt.course.factories import CoursePageFactory
from vbv_lernwelt.course.models import CircleContactType
from vbv_lernwelt.course.utils import get_wagtail_default_site from vbv_lernwelt.course.utils import get_wagtail_default_site
@ -20,6 +21,8 @@ def create_versicherungsvermittlerin_with_categories(
id=course_id, id=course_id,
title=title, title=title,
category_name="Handlungsfeld", category_name="Handlungsfeld",
enable_circle_documents=False,
circle_contact_type=CircleContactType.LEARNING_MENTOR.value,
) )
CourseCategory.objects.get_or_create(course=course, title="Allgemein", general=True) CourseCategory.objects.get_or_create(course=course, title="Allgemein", general=True)

View File

@ -59,6 +59,7 @@ from vbv_lernwelt.course.consts import (
COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID, COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID,
COURSE_VERSICHERUNGSVERMITTLERIN_ID, COURSE_VERSICHERUNGSVERMITTLERIN_ID,
COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID, COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID,
COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID,
) )
from vbv_lernwelt.course.creators.test_course import ( from vbv_lernwelt.course.creators.test_course import (
create_edoniq_test_assignment, create_edoniq_test_assignment,
@ -95,6 +96,7 @@ from vbv_lernwelt.importer.services import (
from vbv_lernwelt.learning_mentor.models import LearningMentor from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.learnpath.create_vv_new_learning_path import ( from vbv_lernwelt.learnpath.create_vv_new_learning_path import (
create_vv_new_learning_path, create_vv_new_learning_path,
create_vv_pruefung_learning_path,
) )
from vbv_lernwelt.learnpath.models import ( from vbv_lernwelt.learnpath.models import (
Circle, Circle,
@ -153,6 +155,9 @@ def command(course):
course_id=COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID, language="it" course_id=COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID, language="it"
) )
if COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID in course:
create_versicherungsvermittlerin_pruefung_course()
if COURSE_UK in course: if COURSE_UK in course:
create_course_uk_de() create_course_uk_de()
create_course_uk_de_course_sessions() create_course_uk_de_course_sessions()
@ -276,6 +281,34 @@ def create_versicherungsvermittlerin_course(
) )
def create_versicherungsvermittlerin_pruefung_course(
course_id=COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID, language="de"
):
names = {
"de": "Versicherungsvermittler/-in VBV Prüfung",
"fr": "Intermédiaire dassurance AFA Examen",
"it": "Intermediario/a assicurativo/a AFA Esame",
}
# Versicherungsvermittler/in mit neuen Circles
course = create_versicherungsvermittlerin_with_categories(
course_id=course_id,
title=names[language],
)
# assignments create assignments parent page
_assignment_list_page = AssignmentListPageFactory(
parent=course.coursepage,
)
create_vv_new_competence_profile(course_id=course_id)
create_default_media_library(course_id=course_id)
create_vv_reflection(course_id=course_id)
CourseSession.objects.create(course_id=course_id, title=names[language])
create_vv_pruefung_learning_path(course_id=course_id)
def create_course_uk_de(course_id=COURSE_UK, lang="de"): def create_course_uk_de(course_id=COURSE_UK, lang="de"):
names = { names = {
"de": "Überbetriebliche Kurse", "de": "Überbetriebliche Kurse",

View File

@ -0,0 +1,23 @@
import djclick as click
from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID
from vbv_lernwelt.course.management.commands.create_default_courses import (
create_versicherungsvermittlerin_pruefung_course,
)
from vbv_lernwelt.course.models import Course
ADMIN_EMAILS = ["info@iterativ.ch", "admin"]
@click.command()
def command():
print(
"Creating Vermittler Prüfung course",
COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID,
)
if Course.objects.filter(id=COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID).exists():
print("Course already exists, skipping")
return
create_versicherungsvermittlerin_pruefung_course()

View File

@ -41,7 +41,7 @@ class Course(models.Model):
return f"/course/{self.slug}" return f"/course/{self.slug}"
def get_cockpit_url(self): def get_cockpit_url(self):
return f"/{self.get_course_url()}/cockpit" return f"{self.get_course_url()}/cockpit"
def get_learning_path(self): def get_learning_path(self):
from vbv_lernwelt.learnpath.models import LearningPath from vbv_lernwelt.learnpath.models import LearningPath

View File

@ -18,11 +18,11 @@ from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.files.models import UploadFile from vbv_lernwelt.files.models import UploadFile
from vbv_lernwelt.files.services import FileDirectUploadService from vbv_lernwelt.files.services import FileDirectUploadService
from vbv_lernwelt.iam.permissions import ( from vbv_lernwelt.iam.permissions import (
can_view_course_completions,
course_sessions_for_user_qs, course_sessions_for_user_qs,
has_course_access, has_course_access,
has_course_access_by_page_request, has_course_access_by_page_request,
is_circle_expert, is_circle_expert,
is_course_session_expert,
) )
from vbv_lernwelt.learning_mentor.models import LearningMentor from vbv_lernwelt.learning_mentor.models import LearningMentor
@ -76,8 +76,8 @@ def request_course_completion(request, course_session_id):
@api_view(["GET"]) @api_view(["GET"])
def request_course_completion_for_user(request, course_session_id, user_id): def request_course_completion_for_user(request, course_session_id, user_id):
if request.user.id == user_id or is_course_session_expert( if can_view_course_completions(
request.user, course_session_id user=request.user, course_session_id=course_session_id, target_user_id=user_id
): ):
return _request_course_completion(course_session_id, user_id) return _request_course_completion(course_session_id, user_id)
raise PermissionDenied() raise PermissionDenied()

View File

@ -18,6 +18,9 @@ def has_course_access(user, course_id):
).exists(): ).exists():
return True return True
if LearningMentor.objects.filter(course_id=course_id, mentor=user).exists():
return True
return CourseSessionUser.objects.filter( return CourseSessionUser.objects.filter(
course_session__course_id=course_id, user=user course_session__course_id=course_id, user=user
).exists() ).exists()
@ -32,16 +35,31 @@ def has_course_session_access(user, course_session_id: int):
).exists() ).exists()
def is_user_mentor(mentor: User, participant_user_id: str, course_session_id: int):
csu = CourseSessionUser.objects.filter(
course_session_id=course_session_id, user_id=participant_user_id
).first()
if csu is None:
return False
return LearningMentor.objects.filter(
course_id=csu.course_session.course_id, mentor=mentor, participants=csu
).exists()
def is_course_session_expert(user, course_session_id: int): def is_course_session_expert(user, course_session_id: int):
if user.is_superuser: if user.is_superuser:
return True return True
course_session = CourseSession.objects.get(id=course_session_id)
is_supervisor = CourseSessionGroup.objects.filter( is_supervisor = CourseSessionGroup.objects.filter(
supervisor=user, course_session__id=course_session_id supervisor=user, course_session=course_session
).exists() ).exists()
is_expert = CourseSessionUser.objects.filter( is_expert = CourseSessionUser.objects.filter(
course_session_id=course_session_id, course_session=course_session,
user=user, user=user,
role=CourseSessionUser.Role.EXPERT, role=CourseSessionUser.Role.EXPERT,
).exists() ).exists()
@ -60,21 +78,29 @@ def is_course_session_member(user, course_session_id: int | None = None):
).exists() ).exists()
def can_evaluate_assignments(user, course_session_id: int): def can_evaluate_assignments(
if user.is_superuser: evaluation_user: User, course_session_id: int, assignment_user_id: str | None = None
):
if evaluation_user.is_superuser:
return True return True
is_supervisor = CourseSessionGroup.objects.filter( is_supervisor = CourseSessionGroup.objects.filter(
supervisor=user, course_session__id=course_session_id supervisor=evaluation_user, course_session__id=course_session_id
).exists() ).exists()
is_expert = CourseSessionUser.objects.filter( is_expert = CourseSessionUser.objects.filter(
course_session_id=course_session_id, course_session_id=course_session_id,
user=user, user=evaluation_user,
role=CourseSessionUser.Role.EXPERT, role=CourseSessionUser.Role.EXPERT,
).exists() ).exists()
return is_supervisor or is_expert is_mentor = is_user_mentor(
mentor=evaluation_user,
participant_user_id=assignment_user_id,
course_session_id=course_session_id,
)
return is_supervisor or is_expert or is_mentor
def course_sessions_for_user_qs(user): def course_sessions_for_user_qs(user):
@ -150,3 +176,34 @@ def has_role_in_course(user: User, course: Course) -> bool:
return True return True
return False return False
def can_view_profile(user: User, profile_user: CourseSessionUser) -> bool:
if user.is_superuser:
return True
if user == profile_user.user:
return True
if is_course_session_expert(user, profile_user.course_session.id) or is_user_mentor(
mentor=user,
participant_user_id=profile_user.user.id,
course_session_id=profile_user.course_session.id,
):
return True
return False
def can_view_course_completions(
user: User, course_session_id: int, target_user_id: str
) -> bool:
return (
user.id == target_user_id
or is_course_session_expert(user=user, course_session_id=course_session_id)
or is_user_mentor(
mentor=user,
participant_user_id=target_user_id,
course_session_id=course_session_id,
)
)

View File

@ -0,0 +1,66 @@
from django.test import TestCase
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.iam.permissions import is_course_session_expert
class ExpertTestCase(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_member(self):
# GIVEN
csu = CourseSessionUser.objects.create(
course_session=self.course_session,
user=self.user,
role=CourseSessionUser.Role.MEMBER,
)
# WHEN
is_expert = is_course_session_expert(
user=csu.user, course_session_id=self.course_session.id
)
# THEN
self.assertFalse(is_expert)
def test_supervisor(self):
# GIVEN
csg = CourseSessionGroup.objects.create(name="Test Group", course=self.course)
csg.course_session.add(self.course_session)
csg.supervisor.add(self.user)
# WHEN
is_expert = is_course_session_expert(
user=self.user, course_session_id=self.course_session.id
)
# THEN
self.assertTrue(is_expert)
def test_expert(self):
# GIVEN
csu = CourseSessionUser.objects.create(
course_session=self.course_session,
user=self.user,
role=CourseSessionUser.Role.EXPERT,
)
# WHEN
is_expert = is_course_session_expert(
user=csu.user, course_session_id=self.course_session.id
)
# THEN
self.assertTrue(is_expert)

View File

@ -8,7 +8,7 @@ from vbv_lernwelt.course.creators.test_utils import (
) )
from vbv_lernwelt.course.models import CourseSessionUser from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.iam.permissions import has_role_in_course from vbv_lernwelt.iam.permissions import has_role_in_course, is_user_mentor
from vbv_lernwelt.learning_mentor.models import LearningMentor from vbv_lernwelt.learning_mentor.models import LearningMentor
@ -59,6 +59,62 @@ class RoleTestCase(TestCase):
# THEN # THEN
self.assertTrue(has_role) self.assertTrue(has_role)
def test_is_user_mentor(self):
# GIVEN
member = create_user("member")
mentor = create_user("mentor")
course, course_page = create_course("Test Course")
course_session = create_course_session(course=course, title=":)")
member_csu = add_course_session_user(
course_session=course_session,
user=member,
role=CourseSessionUser.Role.MEMBER,
)
learning_mentor = LearningMentor.objects.create(
mentor=mentor,
course=course,
)
learning_mentor.participants.add(member_csu)
learning_mentor.save()
# WHEN
is_mentor = is_user_mentor(
mentor=mentor,
participant_user_id=member.id,
course_session_id=course_session.id,
)
# THEN
self.assertTrue(is_mentor)
def test_not_is_user_mentor(self):
# GIVEN
member = create_user("member")
wanna_be_mentor = create_user("wanna_be_mentor")
course, course_page = create_course("Test Course")
course_session = create_course_session(course=course, title=":)")
add_course_session_user(
course_session=course_session,
user=member,
role=CourseSessionUser.Role.MEMBER,
)
# WHEN
is_mentor = is_user_mentor(
mentor=wanna_be_mentor,
participant_user_id=member.id,
course_session_id=course_session.id,
)
# THEN
self.assertFalse(is_mentor)
def test_no_role(self): def test_no_role(self):
# GIVEN # GIVEN
other_course, _ = create_course("Other Test Course") other_course, _ = create_course("Other Test Course")

View File

@ -72,6 +72,7 @@ def get_assignment_completions(
)[0], )[0],
user_id=user.id, user_id=user.id,
last_name=user.last_name, last_name=user.last_name,
url=f"/course/{course_session.course.slug}/cockpit/assignment/{assignment.id}/{user.id}",
) )
for user in sorted_participants for user in sorted_participants
] ]

View File

@ -19,6 +19,7 @@ class MentorAssignmentCompletion:
status: MentorCompletionStatus status: MentorCompletionStatus
user_id: str user_id: str
last_name: str last_name: str
url: str
@dataclass @dataclass

View File

@ -8,6 +8,7 @@ class MentorAssignmentCompletionSerializer(serializers.Serializer):
status = serializers.SerializerMethodField() status = serializers.SerializerMethodField()
user_id = serializers.CharField() user_id = serializers.CharField()
last_name = serializers.CharField() last_name = serializers.CharField()
url = serializers.CharField()
@staticmethod @staticmethod
def get_status(obj): def get_status(obj):

View File

@ -84,6 +84,10 @@ class AttendanceServicesTestCase(TestCase):
for i, result in enumerate(results): for i, result in enumerate(results):
self.assertEqual(result.last_name, expected_order[i]) self.assertEqual(result.last_name, expected_order[i])
self.assertEqual(result.status, expected_statuses[result.last_name]) self.assertEqual(result.status, expected_statuses[result.last_name])
self.assertEqual(
result.url,
f"/course/test-lehrgang/cockpit/assignment/{self.assignment.id}/{result.user_id}",
)
def test_praxis_assignment_status(self): def test_praxis_assignment_status(self):
# GIVEN # GIVEN

View File

@ -87,6 +87,25 @@ def create_vv_new_learning_path(
# all pages belong to 'admin' by default # all pages belong to 'admin' by default
Page.objects.update(owner=user) Page.objects.update(owner=user)
def create_vv_pruefung_learning_path(
course_id=COURSE_VERSICHERUNGSVERMITTLERIN_ID, user=None
):
if user is None:
user = User.objects.get(username="info@iterativ.ch")
course_page = CoursePage.objects.get(course_id=course_id)
lp = LearningPathFactory(
title="Lernpfad",
parent=course_page,
)
TopicFactory(title="Prüfung", parent=lp)
create_circle_pruefungsvorbereitung(lp)
create_circle_pruefung(lp)
# all pages belong to 'admin' by default
Page.objects.update(owner=user)
def create_circle_basis(lp, title="Basis", course_page=None): def create_circle_basis(lp, title="Basis", course_page=None):
circle = CircleFactory( circle = CircleFactory(

View File

@ -0,0 +1,70 @@
import os
from pathlib import Path
from unittest import skipIf
from django.core.files import File
from django.test import RequestFactory, TestCase
from django.urls import reverse
from vbv_lernwelt.course.creators.test_utils import create_user
from vbv_lernwelt.media_files.models import UserImage
TEST_IMAGE = Path(__file__).parent / "test_images" / "user1_profile.jpg"
@skipIf(
os.environ.get("ENABLE_S3_STORAGE_UNIT_TESTS") is None,
"Only enable tests by setting ENABLE_S3_STORAGE_UNIT_TESTS=1",
)
class UserImageViewTest(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.user = create_user("supervisor")
with open(TEST_IMAGE, "rb") as f:
self.user_image, _ = UserImage.objects.get_or_create(
file=File(f, name=TEST_IMAGE.name),
)
def test_image(self):
# GIVEN
self.client.force_login(self.user)
# WHEN
response = self._get_image(self.user_image.id, "fill-300x150")
# THEN
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "image/jpeg")
def test_invalid_id(self):
# GIVEN
image_id = 123456789
self.client.force_login(self.user)
# WHEN
response = self._get_image(image_id)
# THEN
self.assertEqual(response.status_code, 404)
def test_invalid_filter(self):
# GIVEN
self.client.force_login(self.user)
filter_spec = "invalid-filter"
# WHEN
response = self._get_image(self.user_image.id, filter_spec)
# THEN
self.assertEqual(response.status_code, 400)
self.assertEqual(
response.content, f"Invalid filter spec: {filter_spec}".encode()
)
def _get_image(self, image_id, filter_spec=None):
url = reverse("user_image", kwargs={"image_id": image_id})
if filter_spec:
url += f"?filter={filter_spec}"
return self.client.get(url)

View File

@ -0,0 +1,39 @@
import imghdr
from wsgiref.util import FileWrapper
import structlog
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, StreamingHttpResponse
from django.shortcuts import get_object_or_404
from wagtail.images.exceptions import InvalidFilterSpecError
from wagtail.images.models import SourceImageIOError
from vbv_lernwelt.media_files.models import UserImage
logger = structlog.get_logger(__name__)
@login_required
def user_image(request, image_id):
image = get_object_or_404(UserImage, id=image_id)
filter_spec = request.GET.get("filter", "original")
try:
rendition = image.get_rendition(filter_spec)
except SourceImageIOError:
return HttpResponse(
"Source image file not found", content_type="text/plain", status=410
)
except InvalidFilterSpecError:
return HttpResponse(
"Invalid filter spec: " + filter_spec,
content_type="text/plain",
status=400,
)
rendition.file.open("rb")
image_format = imghdr.what(rendition.file)
return StreamingHttpResponse(
FileWrapper(rendition.file), content_type="image/" + image_format
)

View File

@ -9,6 +9,7 @@ from vbv_lernwelt.course_session.models import (
CourseSessionAssignment, CourseSessionAssignment,
CourseSessionEdoniqTest, CourseSessionEdoniqTest,
) )
from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.notify.services import NotificationService from vbv_lernwelt.notify.services import NotificationService
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@ -63,9 +64,12 @@ def send_assignment_reminder_notifications():
AssignmentType.CASEWORK.value, AssignmentType.CASEWORK.value,
], ],
): ):
circle_page = assignment.learning_content.get_parent_circle()
circle = Circle.objects.get(page_ptr=circle_page.id)
for expert in CourseSessionUser.objects.filter( for expert in CourseSessionUser.objects.filter(
course_session_id=assignment.course_session.id, course_session_id=assignment.course_session.id,
role=CourseSessionUser.Role.EXPERT, role=CourseSessionUser.Role.EXPERT,
expert=circle,
): ):
sent.append( sent.append(
NotificationService.send_casework_expert_evaluation_reminder( NotificationService.send_casework_expert_evaluation_reminder(

View File

@ -3,6 +3,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import structlog import structlog
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.db.models import Model from django.db.models import Model
from notifications.signals import notify from notifications.signals import notify
@ -38,11 +40,22 @@ class NotificationService:
def send_assignment_submitted_notification( def send_assignment_submitted_notification(
cls, recipient: User, sender: User, assignment_completion: AssignmentCompletion cls, recipient: User, sender: User, assignment_completion: AssignmentCompletion
): ):
texts = { if (
"de": "%(sender)s hat die geleitete Fallarbeit «%(assignment_title)s» abgegeben.", assignment_completion.assignment.assignment_type
"fr": "%(sender)s a soumis l'étude de cas dirigée «%(assignment_title)s».", == AssignmentType.PRAXIS_ASSIGNMENT.value
"it": "%(sender)s ha consegnato il caso di studio guidato «%(assignment_title)s».", ):
} texts = {
"de": "%(sender)s hat den Praxisauftrag «%(assignment_title)s» abgegeben.",
"fr": "%(sender)s a soumis la mission pratique «%(assignment_title)s».",
"it": "%(sender)s ha consegnato l'incarico pratico «%(assignment_title)s».",
}
# this was the default case before the praxis assignment was introduced
else:
texts = {
"de": "%(sender)s hat die geleitete Fallarbeit «%(assignment_title)s» abgegeben.",
"fr": "%(sender)s a soumis l'étude de cas dirigée «%(assignment_title)s».",
"it": "%(sender)s ha consegnato il caso di studio guidato «%(assignment_title)s».",
}
verb = texts.get(recipient.language, "de") % { verb = texts.get(recipient.language, "de") % {
"sender": sender.get_full_name(), "sender": sender.get_full_name(),
"assignment_title": assignment_completion.assignment.title, "assignment_title": assignment_completion.assignment.title,
@ -68,11 +81,22 @@ class NotificationService:
assignment_completion: AssignmentCompletion, assignment_completion: AssignmentCompletion,
target_url: str, target_url: str,
): ):
texts = { if (
"de": "%(sender)s hat die geleitete Fallarbeit «%(assignment_title)s» bewertet.", assignment_completion.assignment.assignment_type
"fr": "%(sender)s a évalué l'étude de cas dirigée «%(assignment_title)s».", == AssignmentType.PRAXIS_ASSIGNMENT.value
"it": "%(sender)s ha valutato il caso di studio guidato «%(assignment_title)s».", ):
} texts = {
"de": "%(sender)s hat den Praxisauftrag «%(assignment_title)s» bewertet.",
"fr": "%(sender)s a évalué la mission pratique «%(assignment_title)s».",
"it": "%(sender)s ha valutato l'incarico pratico «%(assignment_title)s».",
}
# this was the default case before the praxis assignment was introduced
else:
texts = {
"de": "%(sender)s hat die geleitete Fallarbeit «%(assignment_title)s» bewertet.",
"fr": "%(sender)s a évalué l'étude de cas dirigée «%(assignment_title)s».",
"it": "%(sender)s ha valutato il caso di studio guidato «%(assignment_title)s».",
}
verb = texts.get(recipient.language, "de") % { verb = texts.get(recipient.language, "de") % {
"sender": sender.get_full_name(), "sender": sender.get_full_name(),
"assignment_title": assignment_completion.assignment.title, "assignment_title": assignment_completion.assignment.title,
@ -281,6 +305,13 @@ class NotificationService:
template_data=template_data, template_data=template_data,
) )
emailed = False emailed = False
try:
validate_email(recipient.email)
except ValidationError:
log.info("Recipient email is invalid")
return f"{notification_identifier}_invalid_email"
try: try:
notification = NotificationService._find_duplicate_notification( notification = NotificationService._find_duplicate_notification(
recipient=recipient, recipient=recipient,

View File

@ -6,7 +6,13 @@ from django.test import TestCase
from django.utils import timezone from django.utils import timezone
from freezegun import freeze_time from freezegun import freeze_time
from vbv_lernwelt.assignment.models import AssignmentType from vbv_lernwelt.assignment.models import (
Assignment,
AssignmentCompletion,
AssignmentCompletionStatus,
AssignmentType,
)
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.core.constants import TEST_COURSE_SESSION_BERN_ID from vbv_lernwelt.core.constants import TEST_COURSE_SESSION_BERN_ID
from vbv_lernwelt.core.create_default_users import create_default_users from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.course.creators.test_course import create_test_course from vbv_lernwelt.course.creators.test_course import create_test_course
@ -24,6 +30,7 @@ from vbv_lernwelt.notify.email.reminders.assigment import (
send_assignment_reminder_notifications, send_assignment_reminder_notifications,
) )
from vbv_lernwelt.notify.models import Notification from vbv_lernwelt.notify.models import Notification
from vbv_lernwelt.notify.services import NotificationService
EXPECTED_MEMBER_VERB = "Erinnerung: Bald ist ein Abgabetermin" EXPECTED_MEMBER_VERB = "Erinnerung: Bald ist ein Abgabetermin"
EXPECTED_EXPERT_VERB = "Erinnerung: Bald ist ein Bewertungstermin" EXPECTED_EXPERT_VERB = "Erinnerung: Bald ist ein Bewertungstermin"
@ -40,6 +47,7 @@ ASSIGNMENT_TYPE_LEARNING_CONTENT_LOOKUP: Dict[AssignmentType, str] = {
AssignmentType.PREP_ASSIGNMENT: "test-lehrgang-lp-circle-fahrzeug-lc-fahrzeug-mein-erstes-auto", AssignmentType.PREP_ASSIGNMENT: "test-lehrgang-lp-circle-fahrzeug-lc-fahrzeug-mein-erstes-auto",
AssignmentType.REFLECTION: "test-lehrgang-lp-circle-fahrzeug-lc-reflexion", AssignmentType.REFLECTION: "test-lehrgang-lp-circle-fahrzeug-lc-reflexion",
AssignmentType.CASEWORK: "test-lehrgang-lp-circle-fahrzeug-lc-überprüfen-einer-motorfahrzeug-versicherungspolice", AssignmentType.CASEWORK: "test-lehrgang-lp-circle-fahrzeug-lc-überprüfen-einer-motorfahrzeug-versicherungspolice",
AssignmentType.PRAXIS_ASSIGNMENT: "test-lehrgang-lp-circle-reisen-lc-mein-kundenstamm",
} }
@ -284,3 +292,164 @@ class TestAssignmentCourseRemindersTest(TestCase):
with self.assertRaises(Notification.DoesNotExist): with self.assertRaises(Notification.DoesNotExist):
Notification.objects.get(recipient__username=RECIPIENT_TRAINER) Notification.objects.get(recipient__username=RECIPIENT_TRAINER)
class TestAssignmentCourseUpdateTest(TestCase):
def setUp(self):
create_default_users()
create_test_course(with_sessions=True)
CourseSessionAssignment.objects.all().delete()
Notification.objects.all().delete()
self.student = User.objects.get(email=RECIPIENT_STUDENTS[0])
self.trainer = User.objects.get(email=RECIPIENT_TRAINER)
@freeze_time("2023-01-01")
def test_notification_title_casework_for_experts(self):
# GIVEN
casework = create_assignment(
assignment_type=AssignmentType.CASEWORK,
submission_deadline=timezone.make_aware(datetime(2022, 12, 12)),
evaluation_deadline=timezone.make_aware(datetime(2023, 1, 2)),
)
assignment = Assignment.objects.get(
slug="test-lehrgang-assignment-überprüfen-einer-motorfahrzeugs-versicherungspolice"
)
ac = AssignmentCompletion.objects.create(
completion_status=AssignmentCompletionStatus.SUBMITTED.value,
assignment_user=self.student,
assignment=assignment,
evaluation_passed=True,
course_session=casework.course_session,
completion_data={},
evaluation_max_points=10,
evaluation_points=10,
evaluation_user=self.trainer,
)
# WHEN
NotificationService.send_assignment_submitted_notification(
recipient=self.trainer,
sender=ac.assignment_user,
assignment_completion=ac,
)
# THEN
self.assertEqual(1, len(Notification.objects.all()))
notification = Notification.objects.get(recipient__username=RECIPIENT_TRAINER)
self.assertEqual("USER_INTERACTION", notification.notification_category)
self.assertIn("hat die geleitete Fallarbeit", notification.verb)
def test_notification_title_praxis_assignment_for_experts(self):
# GIVEN.
casework = create_assignment(
assignment_type=AssignmentType.PRAXIS_ASSIGNMENT,
)
assignment = Assignment.objects.get(
slug="test-lehrgang-assignment-mein-kundenstamm"
)
ac = AssignmentCompletion.objects.create(
completion_status=AssignmentCompletionStatus.SUBMITTED.value,
assignment_user=self.student,
assignment=assignment,
evaluation_passed=True,
course_session=casework.course_session,
completion_data={},
evaluation_max_points=10,
evaluation_points=10,
evaluation_user=self.trainer,
)
# WHEN
NotificationService.send_assignment_submitted_notification(
recipient=self.trainer,
sender=ac.assignment_user,
assignment_completion=ac,
)
# THEN
self.assertEqual(1, len(Notification.objects.all()))
notification = Notification.objects.get(recipient__username=RECIPIENT_TRAINER)
self.assertEqual("USER_INTERACTION", notification.notification_category)
self.assertIn("hat den Praxisauftrag", notification.verb)
@freeze_time("2023-01-01")
def test_notification_title_casework_for_student(self):
# GIVEN
casework = create_assignment(
assignment_type=AssignmentType.CASEWORK,
submission_deadline=timezone.make_aware(datetime(2022, 12, 12)),
evaluation_deadline=timezone.make_aware(datetime(2023, 1, 2)),
)
assignment = Assignment.objects.get(
slug="test-lehrgang-assignment-überprüfen-einer-motorfahrzeugs-versicherungspolice"
)
ac = AssignmentCompletion.objects.create(
completion_status=AssignmentCompletionStatus.EVALUATION_SUBMITTED.value,
assignment_user=self.student,
assignment=assignment,
evaluation_passed=True,
course_session=casework.course_session,
completion_data={},
evaluation_max_points=10,
evaluation_points=10,
evaluation_user=self.trainer,
)
# WHEN
NotificationService.send_assignment_evaluated_notification(
recipient=ac.assignment_user,
sender=self.trainer,
assignment_completion=ac,
target_url="/some/url",
)
# THEN
self.assertEqual(1, len(Notification.objects.all()))
notification = Notification.objects.get(recipient__username=self.student.email)
self.assertEqual("USER_INTERACTION", notification.notification_category)
self.assertIn("hat die geleitete Fallarbeit", notification.verb)
@freeze_time("2023-01-01")
def test_notification_title_praxis_assignment_for_student(self):
# GIVEN
casework = create_assignment(
assignment_type=AssignmentType.PRAXIS_ASSIGNMENT,
)
assignment = Assignment.objects.get(
slug="test-lehrgang-assignment-mein-kundenstamm"
)
ac = AssignmentCompletion.objects.create(
completion_status=AssignmentCompletionStatus.EVALUATION_SUBMITTED.value,
assignment_user=self.student,
assignment=assignment,
evaluation_passed=True,
course_session=casework.course_session,
completion_data={},
evaluation_max_points=10,
evaluation_points=10,
evaluation_user=self.trainer,
)
# WHEN
NotificationService.send_assignment_evaluated_notification(
recipient=ac.assignment_user,
sender=self.trainer,
assignment_completion=ac,
target_url="/some/url",
)
# THEN
self.assertEqual(1, len(Notification.objects.all()))
notification = Notification.objects.get(recipient__username=self.student.email)
self.assertEqual("USER_INTERACTION", notification.notification_category)
self.assertIn("hat den Praxisauftrag", notification.verb)