Merged in feature/zulassungsprofil-VBV-666-2024-08-07 (pull request #372)

Feature/zulassungsprofil VBV-666 2024 08 07
This commit is contained in:
Christian Cueni 2024-08-09 11:17:38 +00:00
commit 15f19f5756
63 changed files with 1680 additions and 200 deletions

View File

@ -37,6 +37,7 @@
"devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/client-preset": "^4.3.2",
"@parcel/watcher": "^2.4.1",
"@rushstack/eslint-patch": "^1.8.0",
"@savvywombat/tailwindcss-grid-areas": "^4.0.0",
"@tailwindcss/forms": "^0.5.7",
@ -3013,6 +3014,7 @@
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.4.1.tgz",
"integrity": "sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==",
"dev": true,
"dependencies": {
"detect-libc": "^1.0.3",
"is-glob": "^4.0.3",
@ -3048,6 +3050,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
@ -3067,6 +3070,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@ -3086,6 +3090,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
@ -3105,6 +3110,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
@ -3124,6 +3130,7 @@
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -3143,6 +3150,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -3162,6 +3170,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -3181,6 +3190,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -3200,6 +3210,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
@ -3219,6 +3230,7 @@
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -3238,6 +3250,7 @@
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -3257,6 +3270,7 @@
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
@ -7246,6 +7260,7 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true,
"bin": {
"detect-libc": "bin/detect-libc.js"
},
@ -10481,7 +10496,8 @@
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true
},
"node_modules/node-fetch": {
"version": "2.7.0",
@ -16866,6 +16882,7 @@
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.4.1.tgz",
"integrity": "sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==",
"dev": true,
"requires": {
"@parcel/watcher-android-arm64": "2.4.1",
"@parcel/watcher-darwin-arm64": "2.4.1",
@ -16889,72 +16906,84 @@
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.4.1.tgz",
"integrity": "sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==",
"dev": true,
"optional": true
},
"@parcel/watcher-darwin-arm64": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.4.1.tgz",
"integrity": "sha512-ln41eihm5YXIY043vBrrHfn94SIBlqOWmoROhsMVTSXGh0QahKGy77tfEywQ7v3NywyxBBkGIfrWRHm0hsKtzA==",
"dev": true,
"optional": true
},
"@parcel/watcher-darwin-x64": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.4.1.tgz",
"integrity": "sha512-yrw81BRLjjtHyDu7J61oPuSoeYWR3lDElcPGJyOvIXmor6DEo7/G2u1o7I38cwlcoBHQFULqF6nesIX3tsEXMg==",
"dev": true,
"optional": true
},
"@parcel/watcher-freebsd-x64": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.4.1.tgz",
"integrity": "sha512-TJa3Pex/gX3CWIx/Co8k+ykNdDCLx+TuZj3f3h7eOjgpdKM+Mnix37RYsYU4LHhiYJz3DK5nFCCra81p6g050w==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-arm-glibc": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.4.1.tgz",
"integrity": "sha512-4rVYDlsMEYfa537BRXxJ5UF4ddNwnr2/1O4MHM5PjI9cvV2qymvhwZSFgXqbS8YoTk5i/JR0L0JDs69BUn45YA==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-arm64-glibc": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.4.1.tgz",
"integrity": "sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-arm64-musl": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.4.1.tgz",
"integrity": "sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-x64-glibc": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.4.1.tgz",
"integrity": "sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==",
"dev": true,
"optional": true
},
"@parcel/watcher-linux-x64-musl": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.4.1.tgz",
"integrity": "sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==",
"dev": true,
"optional": true
},
"@parcel/watcher-win32-arm64": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.4.1.tgz",
"integrity": "sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==",
"dev": true,
"optional": true
},
"@parcel/watcher-win32-ia32": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.4.1.tgz",
"integrity": "sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==",
"dev": true,
"optional": true
},
"@parcel/watcher-win32-x64": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.4.1.tgz",
"integrity": "sha512-+DvS92F9ezicfswqrvIRM2njcYJbd5mb9CUgtrHCHmvn7pPPa+nMDRu1o1bYYz/l5IB2NVGNJWiH7h1E58IF2A==",
"dev": true,
"optional": true
},
"@peculiar/asn1-schema": {
@ -19874,7 +19903,8 @@
"detect-libc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="
"integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
"dev": true
},
"didyoumean": {
"version": "1.2.2",
@ -22250,7 +22280,8 @@
"node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"dev": true
},
"node-fetch": {
"version": "2.7.0",

View File

@ -10,8 +10,9 @@
"cypress:open": "cypress open",
"dev": "concurrently \"vite\" \"npm run codegen:watch\"",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"lint:errors": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --quiet --ignore-path .gitignore",
"prettier": "prettier . --write",
"prettier:check": "prettier . --check",
"prettier:check": "prettier . --check --ignore-unknown",
"tailwind": "tailwindcss -i tailwind.css -o ../server/vbv_lernwelt/static/css/tailwind.css --watch",
"test": "vitest run",
"typecheck": "npm run codegen && vue-tsc --noEmit -p tsconfig.app.json --composite false",
@ -47,6 +48,7 @@
"devDependencies": {
"@graphql-codegen/cli": "^5.0.2",
"@graphql-codegen/client-preset": "^4.3.2",
"@parcel/watcher": "^2.4.1",
"@rushstack/eslint-patch": "^1.8.0",
"@savvywombat/tailwindcss-grid-areas": "^4.0.0",
"@tailwindcss/forms": "^0.5.7",

View File

@ -1,6 +1,10 @@
<script setup lang="ts">
import LearningPathCircle from "@/pages/learningPath/learningPathPage/LearningPathCircle.vue";
import { calculateCircleSectorData } from "@/pages/learningPath/learningPathPage/utils";
import {
calculateCircleSectorData,
filterCircles,
useCourseFilter,
} from "@/pages/learningPath/learningPathPage/utils";
import { computed } from "vue";
import { useCourseCircleProgress, useCourseDataWithCompletion } from "@/composables";
import LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
@ -48,9 +52,13 @@ const wrapperClasses = computed(() => {
return classes;
});
const { inProgressCirclesCount, circlesCount } = useCourseCircleProgress(
lpQueryResult.circles
);
const { filter } = useCourseFilter(props.courseSlug, props.courseSessionId);
const filteredCircles = computed(() => {
return filterCircles(filter.value, circles.value);
});
const { inProgressCirclesCount, circlesCount } =
useCourseCircleProgress(filteredCircles);
</script>
<template>
@ -66,7 +74,7 @@ const { inProgressCirclesCount, circlesCount } = useCourseCircleProgress(
</h4>
<div :class="wrapperClasses">
<LearningPathCircle
v-for="circle in circles"
v-for="circle in filteredCircles"
:key="circle.id"
:sectors="calculateCircleSectorData(circle)"
></LearningPathCircle>

View File

@ -1,15 +1,27 @@
<script setup lang="ts">
import ItNavigationProgress from "@/components/ui/ItNavigationProgress.vue";
import { computed } from "vue";
import { useRoute } from "vue-router";
import { isString, startsWith } from "lodash";
const route = useRoute();
const props = defineProps<{
step: number;
}>();
const steps = computed(() => {
const courseType = route.params.courseType;
if (isString(courseType) && startsWith(courseType, "vv-")) {
return 4;
}
return 3;
});
</script>
<template>
<div class="flex h-screen flex-col">
<div class="flex-grow scroll-smooth p-16 lg:overflow-auto">
<ItNavigationProgress :steps="3" :current-step="props.step" />
<ItNavigationProgress :steps="steps" :current-step="props.step" />
<slot name="content"></slot>
</div>

View File

@ -5,17 +5,14 @@ import { computed } from "vue"; // https://stackoverflow.com/questions/64775876/
// https://stackoverflow.com/questions/64775876/vue-3-pass-reactive-object-to-component-with-two-way-binding
interface Props {
modelValue?: {
id: string | number;
name: string;
};
modelValue?: DropdownSelectable;
items?: DropdownSelectable[];
borderless?: boolean;
placeholderText?: string | null;
}
const emit = defineEmits<{
(e: "update:modelValue", data: object): void;
(e: "update:modelValue", data: DropdownSelectable): void;
}>();
const props = withDefaults(defineProps<Props>(), {

View File

@ -3,3 +3,5 @@ export const itCheckboxDefaultIconCheckedTailwindClass =
export const itCheckboxDefaultIconUncheckedTailwindClass =
"bg-[url(/static/icons/icon-checkbox-unchecked.svg)] hover:bg-[url(/static/icons/icon-checkbox-unchecked-hover.svg)]";
export const COURSE_PROFILE_ALL_FILTER = "all";

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -266,6 +266,9 @@ type CourseObjectType {
configuration: CourseConfigurationObjectType!
learning_path: LearningPathObjectType!
action_competences: [ActionCompetenceObjectType!]!
profiles: [String]
course_session_users(id: String): [CourseSessionUserType]!
chosen_profile(user: String!): String
}
type ActionCompetenceObjectType implements CoursePageInterface {
@ -331,45 +334,38 @@ type CircleLightObjectType {
slug: String!
}
type TopicObjectType implements CoursePageInterface {
is_visible: Boolean!
id: ID!
title: String!
slug: String!
content_type: String!
live: Boolean!
translation_key: String!
frontend_url: String!
course: CourseObjectType
circles: [CircleObjectType!]!
type CourseSessionUserType {
id: UUID!
chosen_profile: String!
course_session: CourseSessionObjectType!
}
type CircleObjectType implements CoursePageInterface {
description: String!
goals: String!
"""
Leverages the internal Python implementation of UUID (uuid.UUID) to provide native UUID objects
in fields, resolvers and input.
"""
scalar UUID
type CourseSessionObjectType {
id: ID!
created_at: DateTime!
updated_at: DateTime!
course: CourseObjectType!
title: String!
slug: String!
content_type: String!
live: Boolean!
translation_key: String!
frontend_url: String!
course: CourseObjectType
learning_sequences: [LearningSequenceObjectType!]!
start_date: Date
end_date: Date
attendance_courses: [CourseSessionAttendanceCourseObjectType!]!
assignments: [CourseSessionAssignmentObjectType!]!
edoniq_tests: [CourseSessionEdoniqTestObjectType!]!
users: [CourseSessionUserObjectsType!]!
}
type LearningSequenceObjectType implements CoursePageInterface {
icon: String!
id: ID!
title: String!
slug: String!
content_type: String!
live: Boolean!
translation_key: String!
frontend_url: String!
course: CourseObjectType
learning_units: [LearningUnitObjectType!]!
}
"""
The `Date` scalar type represents a Date
value as specified by
[iso8601](https://en.wikipedia.org/wiki/ISO_8601).
"""
scalar Date
type CourseSessionAttendanceCourseObjectType {
id: ID!
@ -431,26 +427,19 @@ type DueDateObjectType {
course_session: CourseSessionObjectType!
}
type CourseSessionObjectType {
id: ID!
created_at: DateTime!
updated_at: DateTime!
course: CourseObjectType!
title: String!
start_date: Date
end_date: Date
attendance_courses: [CourseSessionAttendanceCourseObjectType!]!
assignments: [CourseSessionAssignmentObjectType!]!
edoniq_tests: [CourseSessionEdoniqTestObjectType!]!
users: [CourseSessionUserObjectsType!]!
type AttendanceUserObjectType {
user_id: UUID!
status: AttendanceUserStatus!
first_name: String
last_name: String
email: String
}
"""
The `Date` scalar type represents a Date
value as specified by
[iso8601](https://en.wikipedia.org/wiki/ISO_8601).
"""
scalar Date
"""An enumeration."""
enum AttendanceUserStatus {
PRESENT
ABSENT
}
type CourseSessionAssignmentObjectType {
id: ID!
@ -578,12 +567,6 @@ type AssignmentCompletionObjectType {
evaluation_max_points: Float
}
"""
Leverages the internal Python implementation of UUID (uuid.UUID) to provide native UUID objects
in fields, resolvers and input.
"""
scalar UUID
type UserObjectType {
"""
Erforderlich. 150 Zeichen oder weniger. Nur Buchstaben, Ziffern und @/./+/-/_.
@ -722,18 +705,46 @@ type CourseSessionUserExpertCircleType {
slug: String!
}
type AttendanceUserObjectType {
user_id: UUID!
status: AttendanceUserStatus!
first_name: String
last_name: String
email: String
type TopicObjectType implements CoursePageInterface {
is_visible: Boolean!
id: ID!
title: String!
slug: String!
content_type: String!
live: Boolean!
translation_key: String!
frontend_url: String!
course: CourseObjectType
circles: [CircleObjectType!]!
}
"""An enumeration."""
enum AttendanceUserStatus {
PRESENT
ABSENT
type CircleObjectType implements CoursePageInterface {
description: String!
goals: String!
is_base_circle: Boolean!
id: ID!
title: String!
slug: String!
content_type: String!
live: Boolean!
translation_key: String!
frontend_url: String!
course: CourseObjectType
learning_sequences: [LearningSequenceObjectType!]!
profiles: [String]!
}
type LearningSequenceObjectType implements CoursePageInterface {
icon: String!
id: ID!
title: String!
slug: String!
content_type: String!
live: Boolean!
translation_key: String!
frontend_url: String!
course: CourseObjectType
learning_units: [LearningUnitObjectType!]!
}
type LearningContentMediaLibraryObjectType implements CoursePageInterface & LearningContentInterface {
@ -896,6 +907,7 @@ type CompetenceCertificateListObjectType implements CoursePageInterface {
type Mutation {
send_feedback(course_session_id: ID!, data: GenericScalar, learning_content_page_id: ID!, learning_content_type: String!, submitted: Boolean = false): SendFeedbackMutation
update_course_session_attendance_course_users(attendance_user_list: [AttendanceUserInputType]!, id: ID!): AttendanceCourseUserMutation
update_course_session_profile(input: CourseSessionProfileMutationInput!): CourseSessionProfileMutationPayload
upsert_assignment_completion(assignment_id: ID!, assignment_user_id: UUID, completion_data_string: String, completion_status: AssignmentCompletionStatus, course_session_id: ID!, evaluation_passed: Boolean, evaluation_points: Float, evaluation_user_id: ID, initialize_completion: Boolean, learning_content_page_id: ID): AssignmentCompletionMutation
}
@ -926,6 +938,27 @@ input AttendanceUserInputType {
status: AttendanceUserStatus!
}
type CourseSessionProfileMutationPayload {
result: UpdateCourseProfileResult
clientMutationId: String
}
union UpdateCourseProfileResult = UpdateCourseProfileError | UpdateCourseProfileSuccess
type UpdateCourseProfileError {
message: String
}
type UpdateCourseProfileSuccess {
user: CourseSessionUserType!
}
input CourseSessionProfileMutationInput {
course_profile: String!
course_slug: String!
clientMutationId: String
}
type AssignmentCompletionMutation {
assignment_completion: AssignmentCompletionObjectType
}

View File

@ -34,8 +34,11 @@ export const CourseSessionAssignmentObjectType = "CourseSessionAssignmentObjectT
export const CourseSessionAttendanceCourseObjectType = "CourseSessionAttendanceCourseObjectType";
export const CourseSessionEdoniqTestObjectType = "CourseSessionEdoniqTestObjectType";
export const CourseSessionObjectType = "CourseSessionObjectType";
export const CourseSessionProfileMutationInput = "CourseSessionProfileMutationInput";
export const CourseSessionProfileMutationPayload = "CourseSessionProfileMutationPayload";
export const CourseSessionUserExpertCircleType = "CourseSessionUserExpertCircleType";
export const CourseSessionUserObjectsType = "CourseSessionUserObjectsType";
export const CourseSessionUserType = "CourseSessionUserType";
export const CourseStatisticsType = "CourseStatisticsType";
export const DashboardConfigType = "DashboardConfigType";
export const DashboardType = "DashboardType";
@ -84,4 +87,7 @@ export const StatisticsCourseSessionsSelectionMetricType = "StatisticsCourseSess
export const String = "String";
export const TopicObjectType = "TopicObjectType";
export const UUID = "UUID";
export const UpdateCourseProfileError = "UpdateCourseProfileError";
export const UpdateCourseProfileResult = "UpdateCourseProfileResult";
export const UpdateCourseProfileSuccess = "UpdateCourseProfileSuccess";
export const UserObjectType = "UserObjectType";

View File

@ -58,3 +58,23 @@ export const UPSERT_ASSIGNMENT_COMPLETION_MUTATION = graphql(`
}
}
`);
export const UPDATE_COURSE_PROFILE_MUTATION = graphql(`
mutation UpdateCourseSessionProfile($input: CourseSessionProfileMutationInput!) {
update_course_session_profile(input: $input) {
clientMutationId
result {
__typename
... on UpdateCourseProfileSuccess {
user {
id
chosen_profile
}
}
... on UpdateCourseProfileError {
message
}
}
}
}
`);

View File

@ -264,18 +264,28 @@ export const COURSE_SESSION_DETAIL_QUERY = graphql(`
`);
export const COURSE_QUERY = graphql(`
query courseQuery($slug: String!) {
query courseQuery($slug: String!, $user: String) {
course(slug: $slug) {
id
title
slug
category_name
profiles
course_session_users(id: $user) {
id
__typename
chosen_profile
course_session {
id
}
}
configuration {
id
enable_circle_documents
enable_learning_mentor
enable_competence_certificates
is_uk
is_vv
}
action_competences {
competence_id
@ -298,6 +308,8 @@ export const COURSE_QUERY = graphql(`
circles {
description
goals
profiles
is_base_circle
...CoursePageFields
learning_sequences {
icon

View File

@ -403,6 +403,13 @@ function log(data: any) {
</button>
</div>
<h2 class="mb-8 mt-8">Tags</h2>
<div class="mb-16 flex flex-col flex-wrap content-center gap-4 lg:flex-row">
<button class="tag-active">Active</button>
<button class="tag-inactive">Inactive</button>
</div>
<h2 class="mb-8 mt-8">Dropdown (Work-in-progress)</h2>
<ItDropdownSelect

View File

@ -0,0 +1,41 @@
<script lang="ts" setup>
import { COURSE_PROFILE_ALL_FILTER } from "@/constants";
import LearningPathCircleListTile from "@/pages/learningPath/learningPathPage/LearningPathCircleListTile.vue";
import type { LearningContentWithCompletion, TopicType } from "@/types";
import { computed } from "vue";
interface Props {
topic: TopicType;
nextLearningContent?: LearningContentWithCompletion;
filter?: string;
}
const props = defineProps<Props>();
const filteredCircles = computed(() => {
if (
props.filter === undefined ||
props.filter === "" ||
props.filter === COURSE_PROFILE_ALL_FILTER
) {
return props.topic.circles;
}
return props.topic.circles.filter(
(circle) =>
circle.profiles.indexOf(props.filter as string) > -1 || circle.is_base_circle
);
});
</script>
<template>
<div>
<div class="pb-2 font-bold text-gray-700">
{{ topic.title }}
</div>
<LearningPathCircleListTile
v-for="circle in filteredCircles"
:key="circle.id"
:circle="circle"
:next-learning-content="nextLearningContent"
></LearningPathCircleListTile>
</div>
</template>

View File

@ -1,26 +1,23 @@
<script setup lang="ts">
import LearningPathCircleListTile from "@/pages/learningPath/learningPathPage/LearningPathCircleListTile.vue";
import { computed } from "vue";
import type { LearningContentWithCompletion, LearningPathType } from "@/types";
import { computed } from "vue";
import LearningPathListTopic from "./LearningPathListTopic.vue";
const props = defineProps<{
learningPath: LearningPathType | undefined;
nextLearningContent: LearningContentWithCompletion | undefined;
filter?: string;
}>();
const topics = computed(() => props.learningPath?.topics ?? []);
</script>
<template>
<div v-for="topic in topics" :key="topic.title">
<div class="pb-2 font-bold text-gray-700">
{{ topic.title }}
</div>
<LearningPathCircleListTile
v-for="circle in topic.circles"
:key="circle.id"
:circle="circle"
:next-learning-content="props.nextLearningContent"
></LearningPathCircleListTile>
</div>
<LearningPathListTopic
v-for="topic in topics"
:key="topic.title"
:topic="topic"
:next-learning-content="nextLearningContent"
:filter="filter"
/>
</template>

View File

@ -3,6 +3,7 @@ import LearningPathListView from "@/pages/learningPath/learningPathPage/Learning
import LearningPathPathView from "@/pages/learningPath/learningPathPage/LearningPathPathView.vue";
import CircleProgress from "@/pages/learningPath/learningPathPage/LearningPathProgress.vue";
import LearningPathTopics from "@/pages/learningPath/learningPathPage/LearningPathTopics.vue";
import LearningPathProfileFilter from "@/pages/learningPath/learningPathPage/LearningPathProfileFilter.vue";
import type { ViewType } from "@/pages/learningPath/learningPathPage/LearningPathViewSwitch.vue";
import LearningPathViewSwitch from "@/pages/learningPath/learningPathPage/LearningPathViewSwitch.vue";
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core";
@ -13,6 +14,9 @@ import {
useCurrentCourseSession,
} from "@/composables";
import CourseSessionDueDatesList from "@/components/dueDates/CourseSessionDueDatesList.vue";
import { useMutation } from "@urql/vue";
import { UPDATE_COURSE_PROFILE_MUTATION } from "@/graphql/mutations";
import { filterCircles, useCourseFilter } from "./utils";
const props = defineProps<{
courseSlug: string;
@ -33,9 +37,28 @@ const course = computed(() => lpQueryResult.course.value);
const courseSession = useCurrentCourseSession();
const { inProgressCirclesCount, circlesCount } = useCourseCircleProgress(
lpQueryResult.circles
);
const { filter } = useCourseFilter(props.courseSlug);
const filteredCircles = computed(() => {
if (lpQueryResult.circles.value === undefined) {
return [];
}
return filterCircles(filter.value, lpQueryResult.circles.value);
});
const { inProgressCirclesCount, circlesCount } =
useCourseCircleProgress(filteredCircles);
const updateCourseProfileMutation = useMutation(UPDATE_COURSE_PROFILE_MUTATION);
const updateCourseProfile = (profile: string) => {
updateCourseProfileMutation.executeMutation({
input: {
course_profile: profile,
course_slug: props.courseSlug,
},
});
};
const changeViewType = (viewType: ViewType) => {
selectedView.value = viewType;
@ -46,7 +69,9 @@ const changeViewType = (viewType: ViewType) => {
<template>
<div class="flex flex-col">
<!-- Top -->
<div class="flex flex-row justify-between space-x-8 bg-gray-200 p-6 sm:p-12">
<div
class="flex flex-col justify-between gap-8 bg-gray-200 p-6 sm:p-12 xl:flex-row"
>
<!-- Left -->
<div class="flex flex-col justify-between lg:w-1/2">
<div>
@ -64,15 +89,21 @@ const changeViewType = (viewType: ViewType) => {
></CircleProgress>
</div>
<!-- Right -->
<div v-if="!useMobileLayout" class="flex-grow">
<!-- todo: find out when to display CourseSessionDueDatesList -->
<div v-if="!useMobileLayout && false" class="flex-grow">
<CourseSessionDueDatesList
:course-session-id="courseSession.id"
:max-count="2"
></CourseSessionDueDatesList>
</div>
<!-- Right -->
<LearningPathProfileFilter
v-if="course?.configuration.is_vv"
:profiles="course?.profiles"
:selected="filter"
@select="updateCourseProfile"
/>
</div>
<!-- Bottom -->
<div class="bg-white">
<div v-if="lpQueryResult.learningPath">
@ -101,6 +132,7 @@ const changeViewType = (viewType: ViewType) => {
<LearningPathPathView
:learning-path="learningPath"
:use-mobile-layout="useMobileLayout"
:filter="filter"
:next-learning-content="lpQueryResult.nextLearningContent.value"
></LearningPathPathView>
</div>
@ -114,6 +146,7 @@ const changeViewType = (viewType: ViewType) => {
<LearningPathListView
:learning-path="learningPath"
:next-learning-content="lpQueryResult.nextLearningContent.value"
:filter="filter"
></LearningPathListView>
</div>
<div

View File

@ -0,0 +1,52 @@
<script setup lang="ts">
import { computed } from "vue";
import type { LearningContentWithCompletion, TopicType } from "@/types";
import LearningPathCircleColumn from "./LearningPathCircleColumn.vue";
import { filterCircles } from "./utils";
interface Props {
topic: TopicType;
topicIndex: number;
nextLearningContent?: LearningContentWithCompletion;
overrideCircleUrlBase?: string;
filter?: string;
isLastTopic: boolean;
}
const props = defineProps<Props>();
const isFirstCircle = (circleIndex: number) =>
props.topicIndex === 0 && circleIndex === 0;
const isLastCircle = (circleIndex: number, numCircles: number) =>
props.isLastTopic && circleIndex === numCircles - 1;
const filteredCircles = computed(() => {
return filterCircles(props.filter, props.topic.circles);
});
</script>
<template>
<div class="basis-40 border-l border-gray-500 first:ml-6 first:sm:ml-12">
<p
:id="`topic-${topic.slug}`"
class="inline-block h-12 self-start px-4 font-bold text-gray-800"
data-cy="lp-topic"
>
{{ topic.title }}
</p>
<div class="flex flex-row pt-6">
<LearningPathCircleColumn
v-for="(circle, circleIndex) in filteredCircles"
:key="circle.id"
:circle="circle"
:next-learning-content="nextLearningContent"
:is-first-circle="isFirstCircle(circleIndex)"
:is-last-circle="isLastCircle(circleIndex, filteredCircles.length)"
:override-circle-url="
overrideCircleUrlBase ? `${overrideCircleUrlBase}/${circle.slug}` : undefined
"
></LearningPathCircleColumn>
</div>
</div>
</template>

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
import LearningPathCircleColumn from "@/pages/learningPath/learningPathPage/LearningPathCircleColumn.vue";
import LearningPathScrollButton from "@/pages/learningPath/learningPathPage/LearningPathScrollButton.vue";
import { useScroll } from "@vueuse/core";
import { ref } from "vue";
import { computed, nextTick, ref, watch } from "vue";
import type { LearningContentWithCompletion, LearningPathType } from "@/types";
import LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
import LearningPathPathTopic from "./LearningPathPathTopic.vue";
const props = defineProps<{
learningPath: LearningPathType | undefined;
@ -12,6 +12,7 @@ const props = defineProps<{
useMobileLayout: boolean;
hideButtons?: boolean;
overrideCircleUrlBase?: string;
filter?: string;
}>();
const scrollIncrement = 600;
@ -19,13 +20,6 @@ const scrollIncrement = 600;
const learnPathDiagram = ref<HTMLElement | null>(null);
const { x, arrivedState } = useScroll(learnPathDiagram, { behavior: "smooth" });
const isFirstCircle = (topicIndex: number, circleIndex: number) =>
topicIndex === 0 && circleIndex === 0;
const isLastCircle = (topicIndex: number, circleIndex: number, numCircles: number) =>
topicIndex === (props.learningPath?.topics ?? []).length - 1 &&
circleIndex === numCircles - 1;
const scrollRight = () => scrollLearnPathDiagram(scrollIncrement);
const scrollLeft = () => scrollLearnPathDiagram(-scrollIncrement);
@ -33,6 +27,22 @@ const scrollLeft = () => scrollLearnPathDiagram(-scrollIncrement);
const scrollLearnPathDiagram = (offset: number) => {
x.value += offset;
};
const topics = computed(() => props.learningPath?.topics ?? []);
watch(
() => props.filter,
() => {
// we need to update the scroll state of the element, otherwise the arrows won't match the scroll state
// https://github.com/vueuse/vueuse/issues/2875
nextTick(() => {
if (learnPathDiagram.value) {
const scrollEvent = new Event("scroll");
learnPathDiagram.value.dispatchEvent(scrollEvent);
}
});
}
);
</script>
<template>
@ -50,37 +60,16 @@ const scrollLearnPathDiagram = (offset: number) => {
ref="learnPathDiagram"
class="no-scrollbar flex h-96 snap-x flex-row overflow-auto py-5 sm:py-10"
>
<div
<LearningPathPathTopic
v-for="(topic, topicIndex) in props.learningPath?.topics ?? []"
:key="topic.title"
class="border-l border-gray-500"
:class="topicIndex == 0 ? 'ml-6 sm:ml-12' : ''"
>
<p
:id="`topic-${topic.slug}`"
class="inline-block h-12 self-start px-4 font-bold text-gray-800"
data-cy="lp-topic"
>
{{ topic.title }}
</p>
<div class="flex flex-row pt-6">
<LearningPathCircleColumn
v-for="(circle, circleIndex) in topic.circles"
:key="circle.id"
:circle="circle"
:next-learning-content="props.nextLearningContent"
:is-first-circle="isFirstCircle(topicIndex, circleIndex)"
:is-last-circle="
isLastCircle(topicIndex, circleIndex, topic.circles.length)
"
:override-circle-url="
props.overrideCircleUrlBase
? `${props.overrideCircleUrlBase}/${circle.slug}`
: undefined
"
></LearningPathCircleColumn>
</div>
</div>
:topic-index="topicIndex"
:topic="topic"
:next-learning-content="nextLearningContent"
:override-circle-url-base="overrideCircleUrlBase"
:filter="filter"
:is-last-topic="topicIndex === topics.length - 1"
/>
</div>
<LearningPathScrollButton
v-show="!arrivedState.right"

View File

@ -0,0 +1,48 @@
<script lang="ts" setup>
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { COURSE_PROFILE_ALL_FILTER } from "@/constants";
import type { DropdownSelectable } from "@/types";
import { useTranslation } from "i18next-vue";
import { computed } from "vue";
interface Props {
profiles?: string[];
selected?: string;
}
const props = defineProps<Props>();
const emit = defineEmits(["select"]);
const { t } = useTranslation();
const items = computed(() => {
return props.profiles?.map((p) => ({ id: p, name: t(`profile.${p}`) })) || [];
});
const selectedItem = computed(() => {
if (props.selected) {
return { id: props.selected || "", name: t(`profile.${props.selected}`) };
}
return {
id: COURSE_PROFILE_ALL_FILTER,
name: t(`profile.${COURSE_PROFILE_ALL_FILTER}`),
};
});
const updateFilter = (e: DropdownSelectable) => {
emit("select", e.id);
};
</script>
<template>
<div class="flex-grow">
<h5 class="mb-4">{{ $t("a.Zulassungsprofil") }}:</h5>
<div class="mb-4 flex gap-4">
<ItDropdownSelect
:items="items"
class="min-w-[18rem]"
:model-value="selectedItem"
@update:model-value="updateFilter"
/>
</div>
</div>
</template>

View File

@ -1,3 +1,6 @@
import { useCurrentCourseSession } from "@/composables";
import { COURSE_PROFILE_ALL_FILTER } from "@/constants";
import { COURSE_QUERY } from "@/graphql/queries";
import type {
CircleSectorData,
CircleSectorProgress,
@ -7,6 +10,8 @@ import {
someFinishedInLearningSequence,
} from "@/services/circle";
import type { CircleType } from "@/types";
import { useQuery } from "@urql/vue";
import { computed } from "vue";
export function calculateCircleSectorData(circle: CircleType): CircleSectorData[] {
return circle.learning_sequences.map((ls) => {
@ -21,3 +26,42 @@ export function calculateCircleSectorData(circle: CircleType): CircleSectorData[
};
});
}
export function useCourseFilter(courseSlug: string, courseSessionId?: string) {
const csId = computed(() => {
if (courseSessionId) {
return courseSessionId;
}
// assume we're on a page with a current course session
const courseSession = useCurrentCourseSession();
return courseSession.value.id;
});
const courseReactiveResult = useQuery({
query: COURSE_QUERY,
variables: { slug: courseSlug },
});
const courseReactive = computed(() => courseReactiveResult.data.value?.course);
const courseSessionUser = computed(() => {
return courseReactive.value?.course_session_users.find(
(e) => e?.course_session.id === csId.value
);
});
const filter = computed(() => {
return courseSessionUser.value?.chosen_profile || "";
});
return {
filter,
courseReactive,
courseSessionUser,
};
}
export function filterCircles(filter: string | undefined, circles: CircleType[]) {
if (filter === undefined || filter === "" || filter === COURSE_PROFILE_ALL_FILTER) {
return circles;
}
return circles.filter(
(circle) => circle.profiles.indexOf(filter as string) > -1 || circle.is_base_circle
);
}

View File

@ -0,0 +1,78 @@
<script setup lang="ts">
import WizardPage from "@/components/onboarding/WizardPage.vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { useEntities } from "@/services/entities";
import { useUserStore } from "@/stores/user";
import type { DropdownSelectable } from "@/types";
import { useTranslation } from "i18next-vue";
import { computed, ref, watch } from "vue";
const { t } = useTranslation();
const user = useUserStore();
const { courseProfiles } = useEntities();
const selectedCourseProfile = ref({
id: 0,
name: t("a.Auswählen"),
});
const validCourseProfile = computed(() => {
return selectedCourseProfile.value.id !== 0;
});
watch(selectedCourseProfile, async (courseProfile: DropdownSelectable) => {
const courseProfileWithCode = courseProfiles.value.find(
(cp) => cp.id === courseProfile.id
);
if (courseProfileWithCode) {
user.updateChosenCourseProfile(courseProfileWithCode);
}
});
const courseProfilesToDropdown = computed(() => {
return courseProfiles.value.map((profile) => ({
...profile,
name: t(`profile.${profile.code}`),
}));
});
</script>
<template>
<WizardPage :step="2">
<template #content>
<h2 class="my-10" data-cy="account-course-profile-title">
{{ $t("a.Zulassungsprofil auswählen") }}
</h2>
<p class="mb-6 max-w-md hyphens-none">
{{
$t(
"a.Wähle ein Zulassungsprofil, damit du deinen Lehrgang an der richtigen Stelle beginnen kannst. Du kannst ihn später jederzeit ändern."
)
}}
</p>
<ItDropdownSelect
v-model="selectedCourseProfile"
:items="courseProfilesToDropdown"
/>
</template>
<template #footer>
<router-link v-slot="{ navigate }" :to="{ name: 'checkoutAddress' }" custom>
<button
:disabled="!validCourseProfile"
class="btn-blue flex items-center"
role="link"
data-cy="continue-button"
@click="navigate"
>
{{ $t("general.next") }}
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
</button>
</router-link>
</template>
</WizardPage>
</template>

View File

@ -229,6 +229,7 @@ const executePayment = async () => {
redirect_url: fullHost,
address: addressData,
product: props.courseType,
chosen_profile: user.chosen_profile?.id || "",
with_cembra_byjuno_invoice: address.value.payment_method === "cembra_byjuno",
device_fingerprint_session_key: getLocalSessionKey(),
}).then((res: any) => {

View File

@ -47,6 +47,14 @@ const { t } = useTranslation();
$t("Füge dein Profilbild hinzu und ergänze die fehlenden Angaben.")
}}
</li>
<li class="relative pl-8">
<span class="font-bold">{{ $t("a.Zulassungsprofil auswählen") }}:</span>
{{
$t(
"a.Wähle ein Zulassungsprofil, damit du deinen Lehrgang an der richtigen Stelle beginnen kannst."
)
}}
</li>
<li class="relative pl-8">
<span class="font-bold">{{ $t("a.Lehrgang kaufen") }}:</span>
{{

View File

@ -1,11 +1,12 @@
<script setup lang="ts">
import { calculateCircleSectorData } from "@/pages/learningPath/learningPathPage/utils";
import { useCourseDataWithCompletion } from "@/composables";
import UserProfileContent from "@/components/userProfile/UserProfileContent.vue";
import LearningPathCircle from "@/pages/learningPath/learningPathPage/LearningPathCircle.vue";
import LearningSequence from "@/pages/learningPath/circlePage/LearningSequence.vue";
import { ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import type { CircleType } from "@/types";
import { COURSE_QUERY } from "@/graphql/queries";
import { useQuery } from "@urql/vue";
import UserProfileTopicList from "./UserProfileTopicList.vue";
const props = defineProps<{
userId: string;
@ -20,6 +21,23 @@ function selectCircle(circle: CircleType) {
selectedCircle.value = circle;
}
const courseReactiveResult = useQuery({
query: COURSE_QUERY,
variables: { slug: props.courseSlug, user: props.userId },
});
const courseReactive = computed(() => courseReactiveResult.data.value?.course);
const courseSessionUsers = computed(() => {
return courseReactive.value?.course_session_users;
});
const chosenProfile = computed(() => {
if (courseSessionUsers.value && courseSessionUsers.value.length > 0) {
return courseSessionUsers.value[0]?.chosen_profile;
}
return undefined;
});
watch(lpQueryResult.learningPath, () => {
if (lpQueryResult.learningPath?.value?.topics?.length) {
selectCircle(lpQueryResult.learningPath.value.topics[0].circles[0]);
@ -30,28 +48,21 @@ watch(lpQueryResult.learningPath, () => {
<template>
<UserProfileContent>
<template #side>
<div
<div v-if="chosenProfile">
<h3 class="mb-4 text-base font-bold">
Zulassungsprofil:
<br />
{{ $t(`profile.${chosenProfile}`) }}
</h3>
</div>
<UserProfileTopicList
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>
:topic="topic"
:filter="chosenProfile"
:selected-circle="selectedCircle"
@select-circle="selectCircle($event)"
/>
</template>
<template #main>
<ol v-if="selectedCircle" class="flex-auto bg-gray-200 px-6 py-4 lg:px-16">

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
import type { CircleType, TopicType } from "@/types";
import { calculateCircleSectorData } from "@/pages/learningPath/learningPathPage/utils";
import LearningPathCircle from "../learningPath/learningPathPage/LearningPathCircle.vue";
import { computed } from "vue";
import { COURSE_PROFILE_ALL_FILTER } from "@/constants";
interface Props {
topic: TopicType;
selectedCircle?: CircleType;
filter?: string;
}
const props = defineProps<Props>();
defineEmits(["select-circle"]);
const filteredCircles = computed(() => {
const circles = props.topic.circles;
const filter = props.filter;
if (filter === undefined || filter === "" || filter === COURSE_PROFILE_ALL_FILTER) {
return circles;
}
return circles.filter(
(circle) => circle.profiles.indexOf(filter as string) > -1 || circle.is_base_circle
);
});
</script>
<template>
<div class="mb-4">
<h4 class="mb-1 font-semibold text-gray-800">{{ topic.title }} {{ filter }}</h4>
<button
v-for="circle in filteredCircles"
: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="$emit('select-circle', 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>

View File

@ -61,7 +61,7 @@ describe("Onboarding", () => {
mockNext
);
expect(mockNext).toHaveBeenCalledWith({
name: "checkoutAddress",
name: "accountCourseProfile",
params: { courseType: testCase },
});
});

View File

@ -391,6 +391,12 @@ const router = createRouter({
component: () => import("@/pages/onboarding/uk/SetupComplete.vue"),
name: "setupComplete",
},
{
path: "account/course-profile",
component: () => import("@/pages/onboarding/vv/AccountCourseProfile.vue"),
name: "accountCourseProfile",
props: true,
},
{
path: "checkout/address",
component: () => import("@/pages/onboarding/vv/CheckoutAddress.vue"),

View File

@ -13,14 +13,21 @@ export type Country = {
name: string;
};
export type CourseProfile = {
id: number;
code: string;
};
export function useEntities() {
const countries: Ref<Country[]> = ref([]);
const organisations: Ref<Organisation[]> = ref([]);
const courseProfiles: Ref<CourseProfile[]> = ref([]);
itGetCached("/api/core/entities/").then((res: any) => {
countries.value = res.countries;
organisations.value = res.organisations;
courseProfiles.value = res.courseProfiles;
});
return { organisations, countries };
return { organisations, countries, courseProfiles };
}

View File

@ -11,7 +11,7 @@ export function profileNextRoute(courseType: string | string[]) {
}
// vv- -> vv-de, vv-fr or vv-it
if (isString(courseType) && startsWith(courseType, "vv-")) {
return "checkoutAddress";
return "accountCourseProfile";
}
return "";
}

View File

@ -1,6 +1,6 @@
import { bustItGetCache, itGetCached, itPost } from "@/fetchHelpers";
import { setI18nLanguage } from "@/i18nextWrapper";
import type { Country } from "@/services/entities";
import type { Country, CourseProfile } from "@/services/entities";
import { directUpload } from "@/services/files";
import dayjs from "dayjs";
import { defineStore } from "pinia";
@ -44,6 +44,7 @@ export interface User {
organisation_postal_code: string;
organisation_city: string;
organisation_country: Country | null;
chosen_profile?: CourseProfile;
}
let defaultLanguage: AvailableLanguages = "de";
@ -89,6 +90,7 @@ const initialUserState: User = {
organisation_postal_code: "",
organisation_city: "",
organisation_country: null,
chosen_profile: undefined,
};
async function setLocale(language: AvailableLanguages) {
@ -176,5 +178,8 @@ export const useUserStore = defineStore({
await itPost("/api/core/me/", profileData, { method: "PUT" });
Object.assign(this.$state, profileData);
},
updateChosenCourseProfile(courseProfile: CourseProfile) {
Object.assign(this.$state, { chosen_profile: courseProfile });
},
},
});

View File

@ -202,6 +202,7 @@ export interface Course {
title: string;
category_name: string;
slug: string;
profiles: string[];
configuration: CourseConfiguration;
}

View File

@ -93,7 +93,7 @@ textarea {
}
.link {
@apply underline underline-offset-2;
@apply cursor-pointer underline underline-offset-2;
}
.link-large {
@ -167,6 +167,14 @@ textarea {
top: 1rem;
transform: translateY(-50%);
}
.tag-inactive {
@apply rounded-full border-2 border-blue-900 px-4 py-2 font-semibold text-blue-900;
}
.tag-active {
@apply rounded-full bg-blue-900 px-4 py-2 font-semibold text-white;
}
}
@layer utilities {
@ -181,7 +189,9 @@ textarea {
}
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
}

View File

@ -1,16 +1,22 @@
// ids for cypress test data
export const ADMIN_USER_ID = "872efd96-3bd7-4a1e-a239-2d72cad9f604"
export const TEST_SUPERVISOR1_USER_ID = "a9a8b741-f115-4521-af2d-7dfef673b8c5"
export const TEST_TRAINER1_USER_ID = "b9e71f59-c44f-4290-b93a-9b3151e9a2fc"
export const TEST_TRAINER2_USER_ID = "299941ae-1e4b-4f45-8180-876c3ad340b4"
export const TEST_STUDENT1_USER_ID = "65c73ad0-6d53-43a9-a4a4-64143f27b03a"
export const TEST_STUDENT2_USER_ID = "19c40d94-15cc-4198-aaad-ef707c4b0900"
export const TEST_STUDENT3_USER_ID = "bcf94dba-53bc-474b-a22d-e4af39aa042b"
export const TEST_MENTOR1_USER_ID = "d1f5f5a9-5b0a-4e1a-9e1a-9e9b5b5e1b1b"
export const TEST_STUDENT1_VV_USER_ID = "5ff59857-8de5-415e-a387-4449f9a0337a"
export const TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID = "7e8ebf0b-e6e2-4022-88f4-6e663ba0a9db"
export const TEST_USER_EMPTY_ID = "daecbabe-4ab9-4edf-a71f-4119042ccb02"
export const ADMIN_USER_ID = "872efd96-3bd7-4a1e-a239-2d72cad9f604";
export const TEST_SUPERVISOR1_USER_ID = "a9a8b741-f115-4521-af2d-7dfef673b8c5";
export const TEST_TRAINER1_USER_ID = "b9e71f59-c44f-4290-b93a-9b3151e9a2fc";
export const TEST_TRAINER2_USER_ID = "299941ae-1e4b-4f45-8180-876c3ad340b4";
export const TEST_STUDENT1_USER_ID = "65c73ad0-6d53-43a9-a4a4-64143f27b03a";
export const TEST_STUDENT2_USER_ID = "19c40d94-15cc-4198-aaad-ef707c4b0900";
export const TEST_STUDENT3_USER_ID = "bcf94dba-53bc-474b-a22d-e4af39aa042b";
export const TEST_MENTOR1_USER_ID = "d1f5f5a9-5b0a-4e1a-9e1a-9e9b5b5e1b1b";
export const TEST_STUDENT1_VV_USER_ID = "5ff59857-8de5-415e-a387-4449f9a0337a";
export const TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID =
"7e8ebf0b-e6e2-4022-88f4-6e663ba0a9db";
export const TEST_USER_EMPTY_ID = "daecbabe-4ab9-4edf-a71f-4119042ccb02";
export const TEST_COURSE_SESSION_BERN_ID = -1;
export const TEST_COURSE_SESSION_ZURICH_ID = -2;
export const TEST_COURSE_SESSION_VV_ID = 1;
export const COURSE_PROFILE_LEBEN_ID = -1;
export const COURSE_PROFILE_NICHTLEBEN_ID = -2;
export const COURSE_PROFILE_KRANKENZUSATZ_ID = -3;
export const COURSE_PROFILE_ALL_ID = -99;

View File

@ -1,4 +1,9 @@
import { TEST_USER_EMPTY_ID } from "../../consts";
import {
COURSE_PROFILE_ALL_ID,
COURSE_PROFILE_NICHTLEBEN_ID,
TEST_COURSE_SESSION_VV_ID,
TEST_USER_EMPTY_ID,
} from "../../consts";
import { login } from "../helpers";
describe("checkout.cy.js", () => {
@ -32,6 +37,15 @@ describe("checkout.cy.js", () => {
cy.get("#organisationDetailName").type("FdH GmbH");
cy.get('[data-cy="continue-button"]').click();
cy.get('[data-cy="account-course-profile-title"]').should(
"have.text",
"Zulassungsprofil auswählen",
);
cy.get('[data-cy="dropdown-select"]').click();
cy.get('[data-cy="dropdown-select-option-Nichtleben"]').click();
cy.get('[data-cy="continue-button"]').click();
cy.loadUser("id", TEST_USER_EMPTY_ID).then((u) => {
expect(u.organisation_detail_name).to.equal("FdH GmbH");
// 2 -> andere Krankenversicherer
@ -121,6 +135,12 @@ describe("checkout.cy.js", () => {
cy.loadCheckoutInformation("user_id", TEST_USER_EMPTY_ID).then((ci) => {
expect(ci.state).to.equal("paid");
});
cy.loadCourseSessionUser("user_id", TEST_USER_EMPTY_ID).then((csu) => {
expect(csu.role).to.equal("MEMBER");
expect(csu.course_session).to.equal(TEST_COURSE_SESSION_VV_ID);
expect(csu.chosen_profile).to.equal(COURSE_PROFILE_NICHTLEBEN_ID);
});
});
it("can checkout and pay Versicherungsvermittlerin with Cembra invoice", () => {
@ -143,6 +163,15 @@ describe("checkout.cy.js", () => {
cy.get('[data-cy="dropdown-select-option-Baloise"]').click();
cy.get('[data-cy="continue-button"]').click();
cy.get('[data-cy="account-course-profile-title"]').should(
"have.text",
"Zulassungsprofil auswählen",
);
cy.get('[data-cy="dropdown-select"]').click();
cy.get('[data-cy="dropdown-select-option-Allbranche"]').click();
cy.get('[data-cy="continue-button"]').click();
// Adressdaten ausfüllen
cy.get('[data-cy="account-checkout-title"]').should(
"contain",
@ -236,5 +265,32 @@ describe("checkout.cy.js", () => {
// 7 -> Baloise
expect(u.organisation).to.equal(7);
});
// pay
cy.get('[data-cy="pay-button"]').click();
cy.get('[data-cy="checkout-success-title"]').should(
"contain",
"Gratuliere",
);
// wait for payment callback
cy.wait(3000);
cy.get('[data-cy="start-vv-button"]').click();
// back on dashboard page
cy.get('[data-cy="db-course-title"]').should(
"contain",
"Versicherungsvermittler",
);
cy.loadCheckoutInformation("user_id", TEST_USER_EMPTY_ID).then((ci) => {
expect(ci.state).to.equal("paid");
});
cy.loadCourseSessionUser("user_id", TEST_USER_EMPTY_ID).then((csu) => {
expect(csu.role).to.equal("MEMBER");
expect(csu.course_session).to.equal(TEST_COURSE_SESSION_VV_ID);
expect(csu.chosen_profile).to.equal(COURSE_PROFILE_ALL_ID);
});
});
});

View File

@ -189,6 +189,17 @@ Cypress.Commands.add("loadUser", (key, value) => {
});
Cypress.Commands.add("loadCourseSessionUser", (key, value) => {
return loadObjectJson(
key,
value,
"vbv_lernwelt.course.models.CourseSessionUser",
"vbv_lernwelt.course.serializers.CypressCourseSessionUserSerializer",
true
);
});
Cypress.Commands.add("makeSelfEvaluation", (answers) => {
for (let i = 0; i < answers.length; i++) {
const answer = answers[i]

View File

@ -7,7 +7,7 @@ echo 'prettier:check'
(cd client && npm run prettier:check)
echo 'lint and typecheck'
(cd client && npm run lint && npm run typecheck)
(cd client && npm run lint:errors && npm run typecheck)
echo 'python ufmt check'
ufmt check server

View File

@ -4,6 +4,8 @@ from rest_framework.response import Response
from vbv_lernwelt.core.models import Country, Organisation
from vbv_lernwelt.core.serializers import CountrySerializer, OrganisationSerializer
from vbv_lernwelt.learnpath.models import CourseProfile
from vbv_lernwelt.learnpath.serializers import CourseProfileSerializer
@api_view(["GET"])
@ -26,4 +28,13 @@ def list_entities(request):
countries = CountrySerializer(
Country.objects.all(), many=True, context=context
).data
return Response({"organisations": organisations, "countries": countries})
course_profiles = CourseProfileSerializer(
CourseProfile.objects.all(), many=True, context=context
).data
return Response(
{
"organisations": organisations,
"countries": countries,
"courseProfiles": course_profiles,
}
)

View File

@ -1,4 +1,3 @@
from django.shortcuts import get_object_or_404
from rest_framework.decorators import api_view, permission_classes
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import IsAuthenticated

View File

@ -8,6 +8,7 @@ from graphql import GraphQLError
from rest_framework.exceptions import PermissionDenied
from vbv_lernwelt.competence.graphql.types import ActionCompetenceObjectType
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import (
CircleDocument,
Course,
@ -29,8 +30,9 @@ from vbv_lernwelt.course_session.models import (
)
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.iam.permissions import has_course_access
from vbv_lernwelt.learnpath.consts import COURSE_PROFILE_ALL_ID
from vbv_lernwelt.learnpath.graphql.types import LearningPathObjectType
from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.learnpath.models import Circle, CourseProfile
logger = structlog.get_logger(__name__)
@ -106,6 +108,12 @@ class CourseObjectType(DjangoObjectType):
graphene.NonNull(ActionCompetenceObjectType), required=True
)
configuration = graphene.Field(CourseConfigurationObjectType, required=True)
profiles = graphene.List(graphene.String)
course_session_users = graphene.List(
"vbv_lernwelt.course.graphql.types.CourseSessionUserType",
required=True,
id=graphene.String(),
)
class Meta:
model = Course
@ -125,6 +133,22 @@ class CourseObjectType(DjangoObjectType):
def resolve_action_competences(root: Course, info):
return root.get_action_competences()
@staticmethod
def resolve_profiles(root: Course, info, **kwargs):
if root.configuration.is_vv:
return CourseProfile.objects.values_list("code", flat=True)
return []
@staticmethod
def resolve_course_session_users(root: Course, info, id=None, **kwargs):
# todo: restrict users that can be queried
if id is not None:
user = User.objects.get(id=id)
else:
user = info.context.user
users = CourseSessionUser.objects.filter(user=user, course_session__course=root)
return users
class CourseSessionUserExpertCircleType(ObjectType):
id = graphene.ID(required=True)
@ -132,6 +156,21 @@ class CourseSessionUserExpertCircleType(ObjectType):
slug = graphene.String(required=True)
class CourseSessionUserType(DjangoObjectType):
chosen_profile = graphene.String(required=True)
course_session = graphene.Field(
"vbv_lernwelt.course.graphql.types.CourseSessionObjectType", required=True
)
class Meta:
model = CourseSessionUser
fields = ["chosen_profile", "id"]
@staticmethod
def resolve_chosen_profile(root: CourseSessionUser, info, **kwargs):
return getattr(root.chosen_profile, "code", "")
class CourseSessionUserObjectsType(ObjectType):
"""
WORKAROUND:

View File

@ -101,6 +101,7 @@ from vbv_lernwelt.learnpath.create_vv_new_learning_path import (
create_vv_new_learning_path,
create_vv_pruefung_learning_path,
)
from vbv_lernwelt.learnpath.creators import assign_circles_to_profiles
from vbv_lernwelt.learnpath.models import (
Circle,
LearningContent,
@ -222,6 +223,7 @@ def create_versicherungsvermittlerin_course(
create_vv_gewinnen_casework(course_id=course_id)
create_vv_reflection(course_id=course_id)
create_vv_new_learning_path(course_id=course_id)
assign_circles_to_profiles()
cs = CourseSession.objects.create(course_id=course_id, title=names[language])

View File

@ -0,0 +1,24 @@
# Generated by Django 3.2.20 on 2024-07-11 09:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0017_auto_20240711_1100"),
("course", "0008_auto_20240403_1132"),
]
operations = [
migrations.AddField(
model_name="coursesessionuser",
name="chosen_profile",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="learnpath.courseprofile",
),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.2.13 on 2024-07-22 19:45
from django.db import migrations, models
import vbv_lernwelt.course.models
class Migration(migrations.Migration):
dependencies = [
("course", "0009_coursesessionuser_chosen_profile"),
]
operations = [
migrations.AlterField(
model_name="coursecompletion",
name="completion_status",
field=models.CharField(
choices=[
("SUCCESS", "Success"),
("FAIL", "Fail"),
("UNKNOWN", "Unknown"),
],
default=vbv_lernwelt.course.models.CourseCompletionStatus["UNKNOWN"],
max_length=255,
),
),
]

View File

@ -0,0 +1,12 @@
# Generated by Django 4.2.13 on 2024-08-07 11:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("course", "0009_coursesessionuser_required_attendance_and_more"),
("course", "0010_alter_coursecompletion_completion_status"),
]
operations = []

View File

@ -285,6 +285,10 @@ class CourseSessionUser(models.Model):
)
optional_attendance = models.BooleanField(default=False)
chosen_profile = models.ForeignKey(
"learnpath.CourseProfile", on_delete=models.SET_NULL, blank=True, null=True
)
class Meta:
constraints = [
UniqueConstraint(

View File

@ -8,8 +8,10 @@ from vbv_lernwelt.course.models import (
CourseCompletion,
CourseConfiguration,
CourseSession,
CourseSessionUser,
)
from vbv_lernwelt.iam.permissions import course_session_permissions
from vbv_lernwelt.learnpath.models import CourseProfile
class CourseConfigurationSerializer(serializers.ModelSerializer):
@ -31,10 +33,23 @@ class CourseSerializer(serializers.ModelSerializer):
configuration = CourseConfigurationSerializer(
read_only=True,
)
course_profiles = serializers.SerializerMethodField()
def get_course_profiles(self, obj):
if obj.configuration.is_vv:
return CourseProfile.objects.all().values_list("code", flat=True)
return []
class Meta:
model = Course
fields = ["id", "title", "category_name", "slug", "configuration"]
fields = [
"id",
"title",
"category_name",
"slug",
"configuration",
"course_profiles",
]
class CourseCategorySerializer(serializers.ModelSerializer):
@ -103,6 +118,12 @@ class CourseSessionSerializer(serializers.ModelSerializer):
return []
class CypressCourseSessionUserSerializer(serializers.ModelSerializer):
class Meta:
model = CourseSessionUser
fields = "__all__"
class CircleDocumentSerializer(serializers.ModelSerializer):
learning_sequence = serializers.SerializerMethodField()

View File

@ -0,0 +1,164 @@
from django.test import RequestFactory, TestCase
from graphene.test import Client
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.schema import schema
from vbv_lernwelt.course.management.commands.create_default_courses import (
create_versicherungsvermittlerin_course,
)
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.learnpath.creators import create_course_profiles
from vbv_lernwelt.learnpath.models import CourseProfile
class CourseGraphQLTestCase(TestCase):
def setUp(self) -> None:
create_default_users()
create_course_profiles()
create_versicherungsvermittlerin_course()
def test_update_course_profile(self):
user = User.objects.get(username="student-vv@eiger-versicherungen.ch")
request = RequestFactory().get("/")
request.user = user
client = Client(schema=schema, context_value=request)
query = """
query CourseQuery($slug: String!) {
course(slug: $slug){
id
profiles
course_session_users {
id
__typename
chosen_profile
course_session {
id
}
}
}
}
"""
slug = "versicherungsvermittler-in"
variables = {"slug": slug}
result = client.execute(query, variables=variables)
self.assertIsNone(result.get("errors"))
data = result.get("data")
course = data.get("course")
profiles = course.get("profiles")
self.assertEqual(
set(profiles),
set(["all", "nichtleben", "leben", "krankenzusatzversicherung"]),
)
course_session_user = course.get("course_session_users")[0]
chosen_profile = course_session_user.get("chosen_profile")
self.assertEqual(chosen_profile, "")
mutation = """
mutation UpdateCourseSessionProfile($input: CourseSessionProfileMutationInput!) {
update_course_session_profile(input: $input) {
clientMutationId
result {
__typename
... on UpdateCourseProfileSuccess {
user {
id
chosen_profile
}
}
... on UpdateCourseProfileError {
message
}
}
}
}
"""
profile = "nichtleben"
input = {"course_profile": profile, "course_slug": slug}
mutation_result = client.execute(mutation, variables={"input": input})
self.assertIsNone(mutation_result.get("errors"))
second_query_result = client.execute(query, variables=variables)
self.assertIsNone(second_query_result.get("errors"))
data = second_query_result.get("data")
course = data.get("course")
profiles = course.get("profiles")
self.assertEqual(
set(profiles),
set(["all", "nichtleben", "leben", "krankenzusatzversicherung"]),
)
course_session_user = course.get("course_session_users")[0]
chosen_profile = course_session_user.get("chosen_profile")
self.assertEqual(chosen_profile, profile)
def test_mentor_profile_view(self):
user = User.objects.get(username="test-mentor1@example.com")
request = RequestFactory().get("/")
request.user = user
client = Client(schema=schema, context_value=request)
query = """
query courseQuery($slug: String!, $user: String) {
course(slug: $slug) {
id
title
slug
category_name
profiles
course_session_users(id: $user) {
id
__typename
chosen_profile
course_session {
id
__typename
}
}
__typename
}
}
"""
student = User.objects.get(username="student-vv@eiger-versicherungen.ch")
slug = "versicherungsvermittler-in"
student_id = str(student.id)
variables = {"slug": slug, "user": student_id}
print(variables)
result = client.execute(query, variables=variables)
self.assertIsNone(result.get("errors"))
data = result.get("data")
course = data.get("course")
profiles = course.get("profiles")
self.assertEqual(
set(profiles),
set(["all", "nichtleben", "leben", "krankenzusatzversicherung"]),
)
course_session_user = course.get("course_session_users")[0]
chosen_profile = course_session_user.get("chosen_profile")
self.assertEqual(chosen_profile, "")
csu = CourseSessionUser.objects.get(
course_session__course__slug=slug, user=student
)
course_profile = CourseProfile.objects.get(code="nichtleben")
csu.chosen_profile = course_profile
csu.save()
second_result = client.execute(query, variables=variables)
self.assertIsNone(second_result.get("errors"))
data = second_result.get("data")
course = data.get("course")
profiles = course.get("profiles")
self.assertEqual(
set(profiles),
set(["all", "nichtleben", "leben", "krankenzusatzversicherung"]),
)
course_session_user = course.get("course_session_users")[0]
chosen_profile = course_session_user.get("chosen_profile")
self.assertEqual(chosen_profile, "nichtleben")

View File

@ -1,7 +1,10 @@
import graphene
import structlog
from graphene import relay
from rest_framework.exceptions import PermissionDenied
from vbv_lernwelt.course.graphql.types import CourseSessionUserType
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.course_session.graphql.types import (
CourseSessionAttendanceCourseObjectType,
)
@ -11,10 +14,33 @@ from vbv_lernwelt.course_session.services.attendance import (
update_attendance_list,
)
from vbv_lernwelt.iam.permissions import has_course_access
from vbv_lernwelt.learnpath.models import CourseProfile
logger = structlog.get_logger(__name__)
class UpdateCourseProfileSuccess(graphene.ObjectType):
user = graphene.Field(CourseSessionUserType(), required=True)
class UpdateCourseProfileError(graphene.ObjectType):
message = graphene.String()
class UpdateCourseProfileResult(graphene.Union):
class Meta:
types = (
UpdateCourseProfileError,
UpdateCourseProfileSuccess,
)
@classmethod
def resolve_type(cls, instance, info):
if type(instance).__name__ == "UpdateCourseProfileSuccess":
return UpdateCourseProfileSuccess
return UpdateCourseProfileError
class AttendanceUserInputType(graphene.InputObjectType):
user_id = graphene.UUID(required=True)
status = graphene.Field(
@ -57,5 +83,40 @@ class AttendanceCourseUserMutation(graphene.Mutation):
)
class CourseSessionProfileMutation(relay.ClientIDMutation):
class Input:
course_profile = graphene.String(required=True)
course_slug = graphene.String(required=True)
result = UpdateCourseProfileResult()
@classmethod
def mutate_and_get_payload(cls, root, info, **input):
course_profile = input.get("course_profile")
course_slug = input.get("course_slug")
user = info.context.user
try:
if course_profile == "":
profile = None
else:
profile = CourseProfile.objects.get(code=course_profile)
# csu = user.coursesessionuser_set.first()
csu = CourseSessionUser.objects.get(
course_session__course__slug=course_slug, user=user
)
csu.chosen_profile = profile
csu.save()
return cls(result=UpdateCourseProfileSuccess(user=csu))
except CourseProfile.DoesNotExist:
return cls(result=UpdateCourseProfileError("Course Profile does not exist"))
except CourseSessionUser.DoesNotExist:
return cls(
result=UpdateCourseProfileError("Course Session User does not exist")
)
class CourseSessionMutation:
update_course_session_attendance_course_users = AttendanceCourseUserMutation.Field()
update_course_session_profile = CourseSessionProfileMutation.Field()

View File

@ -1,3 +1,8 @@
from django.contrib import admin
# Register your models here.
from vbv_lernwelt.learnpath.models import CourseProfile
@admin.register(CourseProfile)
class CourseProfileAdmin(admin.ModelAdmin):
pass

View File

@ -0,0 +1,9 @@
COURSE_PROFILE_LEBEN_ID = -1
COURSE_PROFILE_NICHTLEBEN_ID = -2
COURSE_PROFILE_KRANKENZUSATZ_ID = -3
COURSE_PROFILE_ALL_ID = -99
COURSE_PROFILE_LEBEN_CODE = "leben"
COURSE_PROFILE_NICHTLEBEN_CODE = "nichtleben"
COURSE_PROFILE_KRANKENZUSATZ_CODE = "krankenzusatzversicherung"
COURSE_PROFILE_ALL_CODE = "all"

View File

@ -0,0 +1,192 @@
import structlog
from vbv_lernwelt.course.consts import (
COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID,
COURSE_VERSICHERUNGSVERMITTLERIN_ID,
COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID,
)
from vbv_lernwelt.learnpath.consts import (
COURSE_PROFILE_ALL_CODE,
COURSE_PROFILE_ALL_ID,
COURSE_PROFILE_KRANKENZUSATZ_CODE,
COURSE_PROFILE_KRANKENZUSATZ_ID,
COURSE_PROFILE_LEBEN_CODE,
COURSE_PROFILE_LEBEN_ID,
COURSE_PROFILE_NICHTLEBEN_CODE,
COURSE_PROFILE_NICHTLEBEN_ID,
)
logger = structlog.get_logger(__name__)
def create_course_profiles():
from vbv_lernwelt.learnpath.models import CourseProfile
# Allbranche, Krankenzusatzversicherung, nicht Leben, Leben
CourseProfile.objects.get_or_create(
id=COURSE_PROFILE_ALL_ID, code=COURSE_PROFILE_ALL_CODE, order=1
)
CourseProfile.objects.get_or_create(
id=COURSE_PROFILE_KRANKENZUSATZ_ID,
code=COURSE_PROFILE_KRANKENZUSATZ_CODE,
order=2,
)
CourseProfile.objects.get_or_create(
id=COURSE_PROFILE_NICHTLEBEN_ID, code=COURSE_PROFILE_NICHTLEBEN_CODE, order=3
)
CourseProfile.objects.get_or_create(
id=COURSE_PROFILE_LEBEN_ID, code=COURSE_PROFILE_LEBEN_CODE, order=4
)
def assign_circle_to_profile_curry(course_page):
from vbv_lernwelt.learnpath.models import Circle, CourseProfile
def assign_circle_to_profile(title, code):
try:
circle = Circle.objects.descendant_of(course_page).get(title=title)
course_profile = CourseProfile.objects.get(code=code)
circle.profiles.add(course_profile)
circle.save()
except Circle.DoesNotExist:
logger.warning("assign_circle_to_profile: circle not found", title=title)
return assign_circle_to_profile
def make_base_circle_curry(course_page):
from vbv_lernwelt.learnpath.models import Circle
def make_base_circle(title):
try:
circle = Circle.objects.descendant_of(course_page).get(title=title)
circle.is_base_circle = True
circle.save()
except Circle.DoesNotExist:
logger.warning("assign_circle_to_profile: circle not found", title=title)
return make_base_circle
def assign_de_circles_to_profiles():
from vbv_lernwelt.course.models import CoursePage
try:
course_page = CoursePage.objects.get(
course_id=COURSE_VERSICHERUNGSVERMITTLERIN_ID
)
except CoursePage.DoesNotExist:
logger.warning("Course does not exist yet")
return
assign_circle_to_profile = assign_circle_to_profile_curry(course_page)
make_base_circle = make_base_circle_curry(course_page)
assign_circle_to_profile("Fahrzeug", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Haushalt", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Rechtsstreitigkeiten", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Reisen", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Wohneigentum", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile(
"Selbstständigkeit", COURSE_PROFILE_NICHTLEBEN_CODE
) # typo, but that's how it is in prod data
assign_circle_to_profile("KMU", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Einkommenssicherung", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Pensionierung", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Erben/Vererben", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Sparen", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile(
"Selbstständigkeit", COURSE_PROFILE_LEBEN_CODE
) # typo, but that's how it is in prod data
assign_circle_to_profile("KMU", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Gesundheit", COURSE_PROFILE_KRANKENZUSATZ_CODE)
make_base_circle("Kickoff")
make_base_circle("Basis")
make_base_circle("Gewinnen")
make_base_circle("Prüfungsvorbereitung")
make_base_circle("Prüfung")
def assign_fr_circles_to_profiles():
from vbv_lernwelt.course.models import CoursePage
try:
course_page = CoursePage.objects.get(
course_id=COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID
)
except CoursePage.DoesNotExist:
logger.warning("Course does not exist yet")
return
assign_circle_to_profile = assign_circle_to_profile_curry(course_page)
make_base_circle = make_base_circle_curry(course_page)
assign_circle_to_profile("Véhicule", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Ménage", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Litiges juridiques", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Voyages", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Propriété du logement", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Activité indépendante", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("PME", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Garantie des revenus", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Retraite", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Hériter\xa0/\xa0léguer", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Épargne", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Activité indépendante", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("PME", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Santé", COURSE_PROFILE_KRANKENZUSATZ_CODE)
make_base_circle("Lancement")
make_base_circle("Base")
make_base_circle("Acquisition")
make_base_circle("Préparation à lexamen")
make_base_circle("Lexamen")
def assign_it_circles_to_profiles():
from vbv_lernwelt.course.models import CoursePage
try:
course_page = CoursePage.objects.get(
course_id=COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID
)
except CoursePage.DoesNotExist:
logger.warning("Course does not exist yet")
return
assign_circle_to_profile = assign_circle_to_profile_curry(course_page)
make_base_circle = make_base_circle_curry(course_page)
assign_circle_to_profile("Veicolo", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Economia domestica", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Controversie giuridiche", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Viaggi", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Casa di proprietà", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Attività indipendente", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("PMI", COURSE_PROFILE_NICHTLEBEN_CODE)
assign_circle_to_profile("Protezione del reddito", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Pensionamento", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Ereditare/lasciare in eredità", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Risparmio", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Attività indipendente", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("PMI", COURSE_PROFILE_LEBEN_CODE)
assign_circle_to_profile("Salute", COURSE_PROFILE_KRANKENZUSATZ_CODE)
make_base_circle("Kickoff")
make_base_circle("Base")
make_base_circle("Acquisizione")
make_base_circle("Preparazione all'esame")
make_base_circle("Esame")
def assign_circles_to_profiles():
assign_de_circles_to_profiles()
assign_fr_circles_to_profiles()
assign_it_circles_to_profiles()

View File

@ -1,3 +1,5 @@
import random
import graphene
import structlog
from graphene_django import DjangoObjectType
@ -6,6 +8,7 @@ from vbv_lernwelt.core.utils import find_first_index
from vbv_lernwelt.course.graphql.interfaces import CoursePageInterface
from vbv_lernwelt.learnpath.models import (
Circle,
CourseProfile,
LearningContentAssignment,
LearningContentAttendanceCourse,
LearningContentDocumentList,
@ -299,14 +302,12 @@ class CircleObjectType(DjangoObjectType):
learning_sequences = graphene.List(
graphene.NonNull(LearningSequenceObjectType), required=True
)
profiles = graphene.List(graphene.String, required=True)
class Meta:
model = Circle
interfaces = (CoursePageInterface,)
fields = [
"description",
"goals",
]
fields = ["description", "goals", "is_base_circle"]
def resolve_learning_sequences(self: Circle, info, **kwargs):
circle_descendants = None
@ -335,6 +336,10 @@ class CircleObjectType(DjangoObjectType):
if descendant.specific_class == LearningSequence
]
@staticmethod
def resolve_profiles(root: Circle, info, **kwargs):
return root.profiles.all()
class TopicObjectType(DjangoObjectType):
circles = graphene.List(graphene.NonNull(CircleObjectType), required=True)

View File

@ -0,0 +1,48 @@
# Generated by Django 3.2.20 on 2024-07-11 09:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0016_remove_learningunit_feedback_user"),
]
operations = [
migrations.CreateModel(
name="CourseProfile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("code", models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name="CourseProfileToCircle",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
],
),
migrations.AddField(
model_name="circle",
name="profiles",
field=models.ManyToManyField(
related_name="circles", to="learnpath.CourseProfile"
),
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 4.2.13 on 2024-07-30 07:03
import modelcluster.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0017_auto_20240711_1100"),
]
operations = [
migrations.AlterModelOptions(
name="courseprofile",
options={"ordering": ["order"]},
),
migrations.AddField(
model_name="circle",
name="is_base_circle",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="courseprofile",
name="order",
field=models.IntegerField(default=999),
),
migrations.AlterField(
model_name="circle",
name="profiles",
field=modelcluster.fields.ParentalManyToManyField(
related_name="circles", to="learnpath.courseprofile"
),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.13 on 2024-07-30 07:04
from django.db import migrations
from vbv_lernwelt.learnpath.creators import create_course_profiles
def migrate(apps, schema_editor):
create_course_profiles()
class Migration(migrations.Migration):
dependencies = [
(
"learnpath",
"0018_alter_courseprofile_options_circle_is_base_circle_and_more",
),
]
operations = [migrations.RunPython(migrate)]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.2.13 on 2024-07-30 07:05
from django.db import migrations
from vbv_lernwelt.learnpath.creators import assign_circles_to_profiles
def migrate(apps, schema_editor):
assign_circles_to_profiles()
class Migration(migrations.Migration):
dependencies = [
("learnpath", "0019_auto_20240730_0904"),
]
operations = [migrations.RunPython(migrate)]

View File

@ -3,6 +3,7 @@ from typing import Tuple
from django.db import models
from django.utils.text import slugify
from modelcluster.models import ParentalManyToManyField
from wagtail.admin.panels import FieldPanel, PageChooserPanel
from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page
@ -66,6 +67,25 @@ class Topic(CourseBasePage):
return f"{self.title}"
class CourseProfile(models.Model):
code = models.CharField(max_length=255)
order = models.IntegerField(default=999)
def __str__(self) -> str:
return self.code
class Meta:
ordering = [
"order",
]
class CourseProfileToCircle(models.Model):
# this connects the course profile to a circle, because a circle can be in multiple profiles
# todo: to we even need a through model?
pass
class Circle(CourseBasePage):
parent_page_types = ["learnpath.LearningPath"]
subpage_types = [
@ -95,9 +115,25 @@ class Circle(CourseBasePage):
goals = RichTextField(features=DEFAULT_RICH_TEXT_FEATURES_WITH_HEADER)
profiles = ParentalManyToManyField(CourseProfile, related_name="circles")
# base circles do never belong to a course profile and should also get displayed no matter what profile is chosen
is_base_circle = models.BooleanField(default=False)
# profile = models.ForeignKey(
# ApprovalProfile,
# null=True,
# blank=True,
# on_delete=models.SET_NULL,
# related_name="circles",
# help_text="Zulassungsprofil",
# )
content_panels = Page.content_panels + [
FieldPanel("description"),
FieldPanel("goals"),
FieldPanel("is_base_circle"),
FieldPanel("profiles"),
]
def get_frontend_url(self):

View File

@ -1,3 +1,4 @@
from rest_framework import serializers
from rest_framework.fields import SerializerMethodField
from vbv_lernwelt.competence.serializers import (
@ -6,6 +7,7 @@ from vbv_lernwelt.competence.serializers import (
from vbv_lernwelt.core.utils import get_django_content_type
from vbv_lernwelt.course.serializer_helpers import get_course_serializer_class
from vbv_lernwelt.learnpath.models import (
CourseProfile,
LearningContentAssignment,
LearningContentEdoniqTest,
LearningUnit,
@ -98,3 +100,9 @@ class LearningContentAssignmentSerializer(
}
except Exception:
return None
class CourseProfileSerializer(serializers.ModelSerializer):
class Meta:
model = CourseProfile
fields = ["id", "code"]

View File

@ -0,0 +1,33 @@
# Generated by Django 4.2.13 on 2024-07-22 19:45
import wagtail.images.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("media_files", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="contentimagerendition",
name="file",
field=wagtail.images.models.WagtailImageField(
height_field="height",
storage=wagtail.images.models.get_rendition_storage,
upload_to=wagtail.images.models.get_rendition_upload_to,
width_field="width",
),
),
migrations.AlterField(
model_name="userimagerendition",
name="file",
field=wagtail.images.models.WagtailImageField(
height_field="height",
storage=wagtail.images.models.get_rendition_storage,
upload_to=wagtail.images.models.get_rendition_upload_to,
width_field="width",
),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.20 on 2024-07-11 15:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("shop", "0015_cembra_fields"),
]
operations = [
migrations.AlterField(
model_name="checkoutinformation",
name="refno2",
field=models.CharField(max_length=255),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.2.13 on 2024-07-30 07:03
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"learnpath",
"0018_alter_courseprofile_options_circle_is_base_circle_and_more",
),
("shop", "0016_alter_checkoutinformation_refno2"),
]
operations = [
migrations.AddField(
model_name="checkoutinformation",
name="chosen_profile",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="learnpath.courseprofile",
),
),
]

View File

@ -106,7 +106,9 @@ class CheckoutInformation(models.Model):
null=True,
blank=True,
)
chosen_profile = models.ForeignKey(
"learnpath.CourseProfile", on_delete=models.SET_NULL, null=True, blank=True
)
# webhook metadata
webhook_history = models.JSONField(default=list)

View File

@ -6,6 +6,8 @@ from rest_framework.test import APITestCase
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.core.model_utils import add_countries
from vbv_lernwelt.learnpath.consts import COURSE_PROFILE_ALL_CODE, COURSE_PROFILE_ALL_ID
from vbv_lernwelt.learnpath.models import CourseProfile
from vbv_lernwelt.shop.const import VV_DE_PRODUCT_SKU
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState, Product
from vbv_lernwelt.shop.services import InitTransactionException
@ -50,6 +52,10 @@ class CheckoutAPITestCase(APITestCase):
is_active=True,
)
CourseProfile.objects.get_or_create(
id=COURSE_PROFILE_ALL_ID, code=COURSE_PROFILE_ALL_CODE
)
self.client.login(username=USER_USERNAME, password=USER_PASSWORD)
add_countries(small_set=True)

View File

@ -8,6 +8,8 @@ from rest_framework.permissions import IsAuthenticated
from sentry_sdk import capture_exception
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.learnpath.consts import COURSE_PROFILE_ALL_ID
from vbv_lernwelt.learnpath.models import CourseProfile
from vbv_lernwelt.notify.email.email_services import EmailTemplate, send_email
from vbv_lernwelt.shop.const import (
VV_DE_PRODUCT_SKU,
@ -92,6 +94,7 @@ def checkout_vv(request):
sku = request.data["product"]
base_redirect_url = request.data["redirect_url"]
chosen_profile_id = request.data.get("chosen_profile", COURSE_PROFILE_ALL_ID)
log.info("Checkout requested: sku", user_id=request.user.id, sku=sku)
@ -106,6 +109,11 @@ def checkout_vv(request):
),
)
try:
chosen_profile = CourseProfile.objects.get(id=chosen_profile_id)
except CourseProfile.DoesNotExist:
chosen_profile = CourseProfile.objects.get(id=COURSE_PROFILE_ALL_ID)
checkouts = CheckoutInformation.objects.filter(
user=request.user,
product_sku=sku,
@ -151,6 +159,7 @@ def checkout_vv(request):
"device_fingerprint_session_key", ""
),
# address
chosen_profile=chosen_profile,
**request.data["address"],
)
@ -257,9 +266,11 @@ def create_vv_course_session_user(checkout_info: CheckoutInformation):
_, created = CourseSessionUser.objects.get_or_create(
user=checkout_info.user,
role=CourseSessionUser.Role.MEMBER,
chosen_profile=checkout_info.chosen_profile,
course_session=CourseSession.objects.get(
id=PRODUCT_SKU_TO_COURSE_SESSION_ID[checkout_info.product_sku]
),
# chosen_profile=bla,
)
if created: