Merged in feature/VBV-625-umsetzung-cockpit-ausbildungsverantwortlicher-rebased (pull request #385)

VBV-625 Umsetzung Cockpit Ausbildungsverantwortlicher

Approved-by: Dario Aebersold
Approved-by: Daniel Egger
This commit is contained in:
Elia Bieri 2024-09-19 07:59:25 +00:00
commit df85708013
35 changed files with 1110 additions and 131 deletions

View File

@ -309,3 +309,15 @@ graphql schema.
- The `id` field has to be a string?
- What about the generated types from `codegen`? Hand written types seem to be better.
- The functions in `cacheExchange` should be nearer the concrete implementation...
## Load prod data for testing
1. Checkout the [vbv-devops](https://bitbucket.org/iterativ/iterativ-devops/src/master/) repository
2. Change into the `backups` directory
3. Run `python3 check_vbv_backup.py`. This downloads the latest backup from S3 and restores it to the `vbv-lernwelt` database.
4. Reset all user passwords. Open `shell_plus` in the `server` directory of the `vbv_lernwelt` repository and run
```python
for csu in CourseSessionUser.objects.all():
csu.user.set_password("test")
```

View File

@ -19,6 +19,7 @@
"@vuepic/vue-datepicker": "^8.8.1",
"@vueuse/core": "^10.11.0",
"@vueuse/router": "^10.11.0",
"chart.js": "^4.4.4",
"cypress": "^12.14.0",
"d3": "^7.9.0",
"dayjs": "^1.11.11",
@ -32,6 +33,7 @@
"mitt": "^3.0.1",
"pinia": "^2.1.7",
"vue": "^3.4.31",
"vue-chartjs": "^5.3.1",
"vue-router": "^4.4.0"
},
"devDependencies": {
@ -2969,6 +2971,11 @@
"integrity": "sha512-4M/Mb2CxzuI1CtQhVFs6OC9ceuGPAP6SOWnpLcrdB1TcUHroXbsYDVJNOm32koRMfuCoRACbojcm4dPPcQxu0w==",
"dev": true
},
"node_modules/@kurkle/color": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -6001,6 +6008,17 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"node_modules/chart.js": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz",
"integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/check-error": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
@ -14249,6 +14267,15 @@
}
}
},
"node_modules/vue-chartjs": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.1.tgz",
"integrity": "sha512-rZjqcHBxKiHrBl0CIvcOlVEBwRhpWAVf6rDU3vUfa7HuSRmGtCslc0Oc8m16oAVuk0erzc1FCtH1VCriHsrz+A==",
"peerDependencies": {
"chart.js": "^4.1.1",
"vue": "^3.0.0-0 || ^2.7.0"
}
},
"node_modules/vue-component-type-helpers": {
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.0.26.tgz",
@ -16854,6 +16881,11 @@
"integrity": "sha512-4M/Mb2CxzuI1CtQhVFs6OC9ceuGPAP6SOWnpLcrdB1TcUHroXbsYDVJNOm32koRMfuCoRACbojcm4dPPcQxu0w==",
"dev": true
},
"@kurkle/color": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz",
"integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw=="
},
"@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -18998,6 +19030,14 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"chart.js": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.4.tgz",
"integrity": "sha512-emICKGBABnxhMjUjlYRR12PmOXhJ2eJjEHL2/dZlWjxRAZT1D8xplLFq5M0tMQK8ja+wBS/tuVEJB5C6r7VxJA==",
"requires": {
"@kurkle/color": "^0.3.0"
}
},
"check-error": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
@ -24648,6 +24688,11 @@
"@vue/shared": "3.4.31"
}
},
"vue-chartjs": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.1.tgz",
"integrity": "sha512-rZjqcHBxKiHrBl0CIvcOlVEBwRhpWAVf6rDU3vUfa7HuSRmGtCslc0Oc8m16oAVuk0erzc1FCtH1VCriHsrz+A=="
},
"vue-component-type-helpers": {
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.0.26.tgz",

View File

@ -30,6 +30,7 @@
"@vuepic/vue-datepicker": "^8.8.1",
"@vueuse/core": "^10.11.0",
"@vueuse/router": "^10.11.0",
"chart.js": "^4.4.4",
"cypress": "^12.14.0",
"d3": "^7.9.0",
"dayjs": "^1.11.11",
@ -43,6 +44,7 @@
"mitt": "^3.0.1",
"pinia": "^2.1.7",
"vue": "^3.4.31",
"vue-chartjs": "^5.3.1",
"vue-router": "^4.4.0"
},
"devDependencies": {

View File

@ -0,0 +1,58 @@
<script setup lang="ts">
import { ArcElement, Chart as ChartJS } from "chart.js";
import { computed } from "vue";
import { Pie } from "vue-chartjs";
import { useChosenProfileMapping } from "./composables";
ChartJS.register(ArcElement);
const props = defineProps<{ data: Record<string, number> }>();
const { CHOSEN_PROFILE_TO_COLOR, CHOSEN_PROFILE_TO_NAME } = useChosenProfileMapping();
const data = computed(() => ({
labels: Object.entries(props.data).map(([key]) => key),
datasets: [
{
backgroundColor: Object.entries(props.data).map(
([key]) => CHOSEN_PROFILE_TO_COLOR[key]
),
data: Object.entries(props.data).map(([, value]) => value),
},
],
}));
</script>
w
<template>
<div class="flex flex-row items-center space-x-8">
<div class="size-32">
<Pie
:data="data"
:options="{
indexAxis: 'y',
responsive: true,
datasets: {
pie: {
borderWidth: 0,
},
},
}"
/>
</div>
<div class="flex flex-col space-y-2">
<div
v-for="(value, key) in props.data"
:key="key"
class="flex items-center space-x-2"
:data-cy="`dashboard.stats.trainingResponsible.chart.${CHOSEN_PROFILE_TO_NAME[key]}.${value}`"
>
<div
class="h-4 w-4 rounded-full"
:style="{ backgroundColor: CHOSEN_PROFILE_TO_COLOR[key] }"
></div>
<span class="text-base font-bold">{{ value }}</span>
<span class="text-base">{{ CHOSEN_PROFILE_TO_NAME[key] }}</span>
</div>
</div>
</div>
</template>

View File

@ -10,6 +10,7 @@ import LearningPathDiagram from "@/components/learningPath/LearningPathDiagram.v
import type { DashboardCourseConfigType, WidgetType } from "@/services/dashboard";
import { getCockpitUrl, getLearningMentorUrl, getLearningPathUrl } from "@/utils/utils";
import { computed } from "vue";
import TrainingResponsibleStatistics from "./TrainingResponsibleStatistics.vue";
const mentorWidgets = [
"MentorTasksWidget",
@ -71,6 +72,13 @@ const actionButtonProps = computed<{ href: string; text: string; cyKey: string }
cyKey: "progress-dashboard-continue-course-link",
};
}
if (props.courseConfig?.role_key === "Ausbildungsverantwortlicher") {
return {
href: getLearningPathUrl(props.courseConfig?.course_slug),
text: "a.Vorschau Teilnehmer",
cyKey: "tr-dashboard-link",
};
}
return {
href: getLearningPathUrl(props.courseConfig?.course_slug),
text: "Weiter lernen",
@ -193,6 +201,12 @@ function hasActionButton(): boolean {
:agent-role="courseConfig.role_key"
/>
</div>
<TrainingResponsibleStatistics
v-if="hasWidget('TrainingResponsibleStatisticsWidget')"
:course-id="courseConfig.course_id"
:course-session-id="courseConfig.session_to_continue_id"
></TrainingResponsibleStatistics>
</div>
</div>
</template>

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import type { TrainingResponsibleStatisticsQuery } from "@/gql/graphql";
import { fetchTrainingResponsibleStatistics } from "@/services/dashboard";
import { formatCurrencyChfCentimes } from "@/utils/format_currency_chf";
import { computed, onMounted, ref } from "vue";
import AttendancePerChosenProfileChart from "./AttendancePerChosenProfileChart.vue";
import BaseBox from "./BaseBox.vue";
const props = defineProps<{
courseId: string;
courseSessionId: string;
}>();
const statistics = ref<TrainingResponsibleStatisticsQuery | null>(null);
onMounted(async () => {
statistics.value = await fetchTrainingResponsibleStatistics(props.courseSessionId);
});
const currentYear = new Date().getFullYear();
const totalCostInCurrentYear = computed(() => {
return statistics.value?.training_responsible_statistics?.cost_per_year.find(
(cost) => cost?.year === new Date().getFullYear()
)?.total_cost;
});
const attendanceCountInCurrentYear = computed(() => {
return (
statistics.value?.training_responsible_statistics?.participants_per_year?.find(
(entry) => entry?.year === new Date().getFullYear()
)?.participants?.length ?? 0
);
});
const attendanceCountPerChosenProfile = computed(() => {
const allAttendances =
statistics.value?.training_responsible_statistics?.participants_per_year
?.filter((entry) => entry?.year === currentYear)
?.flatMap((entry) => entry?.participants ?? []);
return allAttendances?.reduce(
(acc, attendance) => {
const chosenProfile = attendance?.chosen_profile || "all";
if (!acc[chosenProfile]) {
acc[chosenProfile] = 0;
}
acc[chosenProfile] += 1;
return acc;
},
{} as Record<string, number>
);
});
</script>
<template>
<div class="flex flex-col">
<div class="flex flex-col flex-wrap items-stretch md:flex-row">
<BaseBox
:details-link="`/dashboard/cost/${courseSessionId}`"
data-cy="dashboard.stats.trainingResponsible.cost"
class="w-1/2"
>
<template #title>{{ $t("Kosten im") }} {{ currentYear }}</template>
<template #content>
<div class="flex flex-row space-x-3 bg-white pb-6">
<div class="flex h-[74px] items-center justify-center py-1 pr-3">
<span
class="text-3xl font-bold"
data-cy="dashboard.stats.trainingResponsible.totalCost"
>
{{ formatCurrencyChfCentimes(totalCostInCurrentYear) }}
</span>
<span class="ml-4">CHF</span>
</div>
</div>
</template>
</BaseBox>
<BaseBox
:details-link="`/dashboard/persons?course=${courseId}&selectedPaidYear=${currentYear}`"
data-cy="dashboard.stats.trainingResponsible.participants"
>
<template #title>{{ $t("Teilnehmer im") }} {{ currentYear }}</template>
<template #content>
<div class="flex flex-row space-x-3 bg-white pb-6">
<div
class="flex h-[74px] items-center justify-center py-1 pr-3 text-3xl font-bold"
data-cy="dashboard.stats.trainingResponsible.participantsInCurrentYear"
>
<span>{{ attendanceCountInCurrentYear }}</span>
</div>
<p class="ml-3 mt-0 leading-[74px]">
{{ $t("Teilnehmer") }}
</p>
</div>
</template>
</BaseBox>
</div>
<div class="my-6 space-y-6 border-t border-gray-500 py-6">
<h2 class="text-base font-bold">
{{ $t("Teilnehmer nach Zulassungsprofilen im") }} {{ currentYear }}
</h2>
<AttendancePerChosenProfileChart
v-if="attendanceCountPerChosenProfile"
:data="attendanceCountPerChosenProfile"
></AttendancePerChosenProfileChart>
</div>
</div>
</template>

View File

@ -0,0 +1,25 @@
import colors from "@/colors.json";
import { useTranslation } from "i18next-vue";
export function useChosenProfileMapping() {
const { t } = useTranslation();
const CHOSEN_PROFILE_TO_NAME: Record<string, string> = {
all: t("a.Allbranche"),
leben: t("a.Leben"),
nichtleben: t("a.Nichtleben"),
krankenzusatzversicherung: t("a.Krankenzusatzversicherung"),
};
const CHOSEN_PROFILE_TO_COLOR: Record<string, string> = {
all: colors.blue[500],
leben: colors.orange[500],
nichtleben: colors.green[500],
krankenzusatzversicherung: colors.yellow[400],
};
return {
CHOSEN_PROFILE_TO_NAME,
CHOSEN_PROFILE_TO_COLOR,
};
}

View File

@ -28,6 +28,7 @@ const documents = {
"\n query dashboardCourseData($courseId: ID!) {\n course_progress(course_id: $courseId) {\n _id\n course_id\n session_to_continue_id\n }\n }\n": types.DashboardCourseDataDocument,
"\n query courseStatistics($courseId: ID!) {\n course_statistics(course_id: $courseId) {\n _id\n course_id\n course_title\n course_slug\n course_session_properties {\n _id\n sessions {\n id\n name\n region\n }\n generations\n circles {\n id\n name\n }\n }\n course_session_selection_ids\n course_session_selection_metrics {\n _id\n session_count\n participant_count\n expert_count\n }\n attendance_day_presences {\n _id\n records {\n _id\n course_session_id\n generation\n region\n circle_id\n due_date\n participants_present\n participants_total\n details_url\n }\n summary {\n _id\n days_completed\n participants_present\n }\n }\n feedback_responses {\n _id\n records {\n _id\n course_session_id\n generation\n region\n circle_id\n experts\n satisfaction_average\n satisfaction_max\n details_url\n }\n summary {\n _id\n satisfaction_average\n satisfaction_max\n total_responses\n }\n }\n assignments {\n _id\n summary {\n _id\n completed_count\n average_passed\n total_passed\n total_failed\n }\n records {\n _id\n course_session_id\n course_session_assignment_id\n circle_id\n generation\n region\n assignment_title\n assignment_type_translation_key\n competence_certificate_title\n competence_certificate_id\n details_url\n deadline\n metrics {\n _id\n passed_count\n failed_count\n unranked_count\n ranking_completed\n average_evaluation_percent\n average_passed\n competence_certificate_weight\n }\n }\n }\n competences {\n _id\n summary {\n _id\n success_total\n fail_total\n }\n records {\n _id\n course_session_id\n generation\n region\n circle_id\n title\n success_count\n fail_count\n details_url\n }\n }\n }\n }\n": types.CourseStatisticsDocument,
"\n query mentorCourseStatistics($courseId: ID!, $agentRole: String!) {\n mentor_course_statistics(course_id: $courseId, agent_role: $agentRole) {\n _id\n course_id\n course_title\n course_slug\n course_session_selection_ids\n user_selection_ids\n course_session_properties {\n _id\n sessions {\n id\n name\n region\n }\n generations\n circles {\n id\n name\n }\n }\n assignments {\n _id\n summary {\n _id\n completed_count\n average_passed\n total_passed\n total_failed\n average_evaluation_percent\n }\n records {\n _id\n course_session_id\n course_session_assignment_id\n course_session_title\n circle_id\n generation\n region\n assignment_title\n assignment_type_translation_key\n competence_certificate_id\n competence_certificate_title\n details_url\n learning_content_id\n deadline\n metrics {\n _id\n passed_count\n failed_count\n unranked_count\n ranking_completed\n competence_certificate_weight\n average_evaluation_percent\n average_passed\n }\n }\n }\n }\n }\n": types.MentorCourseStatisticsDocument,
"\n query trainingResponsibleStatistics($courseSessionId: ID!) {\n training_responsible_statistics(course_session_id: $courseSessionId) {\n _id\n cost_per_year {\n _id\n year\n total_cost\n }\n participants_per_year {\n _id\n year\n participants {\n id\n chosen_profile\n }\n }\n }\n }\n": types.TrainingResponsibleStatisticsDocument,
"\n mutation SendFeedbackMutation(\n $courseSessionId: ID!\n $learningContentId: ID!\n $learningContentType: String!\n $data: GenericScalar!\n $submitted: Boolean\n ) {\n send_feedback(\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n learning_content_type: $learningContentType\n data: $data\n submitted: $submitted\n ) {\n feedback_response {\n id\n data\n submitted\n }\n errors {\n field\n messages\n }\n }\n }\n": types.SendFeedbackMutationDocument,
};
@ -105,6 +106,10 @@ export function graphql(source: "\n query courseStatistics($courseId: ID!) {\n
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query mentorCourseStatistics($courseId: ID!, $agentRole: String!) {\n mentor_course_statistics(course_id: $courseId, agent_role: $agentRole) {\n _id\n course_id\n course_title\n course_slug\n course_session_selection_ids\n user_selection_ids\n course_session_properties {\n _id\n sessions {\n id\n name\n region\n }\n generations\n circles {\n id\n name\n }\n }\n assignments {\n _id\n summary {\n _id\n completed_count\n average_passed\n total_passed\n total_failed\n average_evaluation_percent\n }\n records {\n _id\n course_session_id\n course_session_assignment_id\n course_session_title\n circle_id\n generation\n region\n assignment_title\n assignment_type_translation_key\n competence_certificate_id\n competence_certificate_title\n details_url\n learning_content_id\n deadline\n metrics {\n _id\n passed_count\n failed_count\n unranked_count\n ranking_completed\n competence_certificate_weight\n average_evaluation_percent\n average_passed\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query mentorCourseStatistics($courseId: ID!, $agentRole: String!) {\n mentor_course_statistics(course_id: $courseId, agent_role: $agentRole) {\n _id\n course_id\n course_title\n course_slug\n course_session_selection_ids\n user_selection_ids\n course_session_properties {\n _id\n sessions {\n id\n name\n region\n }\n generations\n circles {\n id\n name\n }\n }\n assignments {\n _id\n summary {\n _id\n completed_count\n average_passed\n total_passed\n total_failed\n average_evaluation_percent\n }\n records {\n _id\n course_session_id\n course_session_assignment_id\n course_session_title\n circle_id\n generation\n region\n assignment_title\n assignment_type_translation_key\n competence_certificate_id\n competence_certificate_title\n details_url\n learning_content_id\n deadline\n metrics {\n _id\n passed_count\n failed_count\n unranked_count\n ranking_completed\n competence_certificate_weight\n average_evaluation_percent\n average_passed\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query trainingResponsibleStatistics($courseSessionId: ID!) {\n training_responsible_statistics(course_session_id: $courseSessionId) {\n _id\n cost_per_year {\n _id\n year\n total_cost\n }\n participants_per_year {\n _id\n year\n participants {\n id\n chosen_profile\n }\n }\n }\n }\n"): (typeof documents)["\n query trainingResponsibleStatistics($courseSessionId: ID!) {\n training_responsible_statistics(course_session_id: $courseSessionId) {\n _id\n cost_per_year {\n _id\n year\n total_cost\n }\n participants_per_year {\n _id\n year\n participants {\n id\n chosen_profile\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,7 @@
type Query {
course_statistics(course_id: ID!): CourseStatisticsType
mentor_course_statistics(course_id: ID!, agent_role: String!): BaseStatisticsType
training_responsible_statistics(course_session_id: ID!): TrainingResponsibleStatisticsType
course_progress(course_id: ID!): CourseProgressType
dashboard_config: [DashboardConfigType!]!
learning_path(id: ID, slug: String, course_id: ID, course_slug: String): LearningPathObjectType
@ -200,42 +201,61 @@ type BaseStatisticsType {
course_session_properties: StatisticsCourseSessionPropertiesType!
}
type CourseProgressType {
type TrainingResponsibleStatisticsType {
_id: ID!
course_id: ID!
session_to_continue_id: ID
competence: ProgressDashboardCompetenceType
assignment: ProgressDashboardAssignmentType
course_session_id: ID!
cost_per_year: [TrainingsResponsibleCostForYear]!
participants_per_year: [TrainingsResponsibleParticipantsForYear]!
}
type ProgressDashboardCompetenceType {
type TrainingsResponsibleCostForYear {
_id: ID!
total_count: Int!
success_count: Int!
fail_count: Int!
year: Int!
total_cost: Int!
}
type ProgressDashboardAssignmentType {
type TrainingsResponsibleParticipantsForYear {
_id: ID!
total_count: Int!
points_max_count: Int!
points_achieved_count: Int!
year: Int!
participants: [CourseSessionUserType]!
}
type DashboardConfigType {
type CourseSessionUserType {
id: UUID!
chosen_profile: String!
course_session: CourseSessionObjectType!
}
"""
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!
name: String!
slug: String!
dashboard_type: DashboardType!
course_configuration: CourseConfigurationObjectType!
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!]!
}
enum DashboardType {
STATISTICS_DASHBOARD
PROGRESS_DASHBOARD
SIMPLE_DASHBOARD
MENTOR_DASHBOARD
PRAXISBILDNER_DASHBOARD
type CourseObjectType {
id: ID!
title: String!
category_name: String!
slug: String!
configuration: CourseConfigurationObjectType!
learning_path: LearningPathObjectType!
action_competences: [ActionCompetenceObjectType!]!
profiles: [String]
course_session_users(id: String): [CourseSessionUserType]!
}
type CourseConfigurationObjectType {
@ -270,20 +290,8 @@ interface CoursePageInterface {
course: CourseObjectType
}
type CourseObjectType {
id: ID!
title: String!
category_name: String!
slug: String!
configuration: CourseConfigurationObjectType!
learning_path: LearningPathObjectType!
action_competences: [ActionCompetenceObjectType!]!
profiles: [String]
course_session_users(id: String): [CourseSessionUserType]!
}
type ActionCompetenceObjectType implements CoursePageInterface {
competence_id: String!
type TopicObjectType implements CoursePageInterface {
is_visible: Boolean!
id: ID!
title: String!
slug: String!
@ -292,11 +300,13 @@ type ActionCompetenceObjectType implements CoursePageInterface {
translation_key: String!
frontend_url: String!
course: CourseObjectType
performance_criteria: [PerformanceCriteriaObjectType!]!
circles: [CircleObjectType!]!
}
type PerformanceCriteriaObjectType implements CoursePageInterface {
competence_id: String!
type CircleObjectType implements CoursePageInterface {
description: String!
goals: String!
is_base_circle: Boolean!
id: ID!
title: String!
slug: String!
@ -305,7 +315,21 @@ type PerformanceCriteriaObjectType implements CoursePageInterface {
translation_key: String!
frontend_url: String!
course: CourseObjectType
learning_unit: LearningUnitObjectType
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 LearningUnitObjectType implements CoursePageInterface {
@ -345,30 +369,30 @@ type CircleLightObjectType {
slug: String!
}
type CourseSessionUserType {
id: UUID!
chosen_profile: String!
course_session: CourseSessionObjectType!
type PerformanceCriteriaObjectType implements CoursePageInterface {
competence_id: String!
id: ID!
title: String!
slug: String!
content_type: String!
live: Boolean!
translation_key: String!
frontend_url: String!
course: CourseObjectType
learning_unit: LearningUnitObjectType
}
"""
Leverages the internal Python implementation of UUID (uuid.UUID) to provide native UUID objects
in fields, resolvers and input.
"""
scalar UUID
type CourseSessionObjectType {
type ActionCompetenceObjectType implements CoursePageInterface {
competence_id: String!
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!]!
slug: String!
content_type: String!
live: Boolean!
translation_key: String!
frontend_url: String!
course: CourseObjectType
performance_criteria: [PerformanceCriteriaObjectType!]!
}
"""
@ -717,46 +741,42 @@ type CourseSessionUserExpertCircleType {
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 CourseProgressType {
_id: ID!
course_id: ID!
session_to_continue_id: ID
competence: ProgressDashboardCompetenceType
assignment: ProgressDashboardAssignmentType
}
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 ProgressDashboardCompetenceType {
_id: ID!
total_count: Int!
success_count: Int!
fail_count: Int!
}
type LearningSequenceObjectType implements CoursePageInterface {
icon: String!
type ProgressDashboardAssignmentType {
_id: ID!
total_count: Int!
points_max_count: Int!
points_achieved_count: Int!
}
type DashboardConfigType {
id: ID!
title: String!
name: String!
slug: String!
content_type: String!
live: Boolean!
translation_key: String!
frontend_url: String!
course: CourseObjectType
learning_units: [LearningUnitObjectType!]!
dashboard_type: DashboardType!
course_configuration: CourseConfigurationObjectType!
}
enum DashboardType {
STATISTICS_DASHBOARD
PROGRESS_DASHBOARD
SIMPLE_DASHBOARD
MENTOR_DASHBOARD
PRAXISBILDNER_DASHBOARD
}
type LearningContentMediaLibraryObjectType implements CoursePageInterface & LearningContentInterface {

View File

@ -86,6 +86,9 @@ export const StatisticsCourseSessionPropertiesType = "StatisticsCourseSessionPro
export const StatisticsCourseSessionsSelectionMetricType = "StatisticsCourseSessionsSelectionMetricType";
export const String = "String";
export const TopicObjectType = "TopicObjectType";
export const TrainingResponsibleStatisticsType = "TrainingResponsibleStatisticsType";
export const TrainingsResponsibleCostForYear = "TrainingsResponsibleCostForYear";
export const TrainingsResponsibleParticipantsForYear = "TrainingsResponsibleParticipantsForYear";
export const UUID = "UUID";
export const UpdateCourseProfileError = "UpdateCourseProfileError";
export const UpdateCourseProfileResult = "UpdateCourseProfileResult";

View File

@ -607,3 +607,24 @@ export const DASHBOARD_MENTOR_COMPETENCE_SUMMARY = graphql(`
}
}
`);
export const TRAINING_RESPONSIBLE_STATISTICS = graphql(`
query trainingResponsibleStatistics($courseSessionId: ID!) {
training_responsible_statistics(course_session_id: $courseSessionId) {
_id
cost_per_year {
_id
year
total_cost
}
participants_per_year {
_id
year
participants {
id
chosen_profile
}
}
}
}
`);

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
import type { TrainingResponsibleStatisticsQuery } from "@/gql/graphql";
import { fetchTrainingResponsibleStatistics } from "@/services/dashboard";
import { formatCurrencyChfCentimes } from "@/utils/format_currency_chf";
import { onMounted, ref } from "vue";
const props = defineProps<{
courseSessionId: string;
}>();
const loading = ref(false);
const statistics = ref<TrainingResponsibleStatisticsQuery | null>(null);
onMounted(async () => {
statistics.value = await fetchTrainingResponsibleStatistics(props.courseSessionId);
loading.value = false;
});
const participantsForYear = (year: number) => {
return statistics.value?.training_responsible_statistics?.participants_per_year.find(
(entry) => entry?.year === year
)?.participants.length;
};
</script>
<template>
<div>
<div v-if="loading" class="m-8 flex justify-center">
<LoadingSpinner />
</div>
<div v-else class="h-screen bg-gray-200">
<div class="container-large">
<router-link
:to="`/`"
class="btn-text inline-flex items-center p-0"
data-cy="back-to-learning-path-button"
>
<it-icon-arrow-left class="-ml-1 mr-1 h-5 w-5"></it-icon-arrow-left>
<span class="inline">{{ $t("general.back") }}</span>
</router-link>
<h2 class="my-8">{{ $t("a.Kosten") }}</h2>
<div class="bg-white p-6">
<div
v-for="entry in statistics?.training_responsible_statistics?.cost_per_year"
:key="entry?.year"
class="w-full border-b pb-2 pt-2 first:pt-0 last:border-b-0 last:pb-0"
>
<div class="flex w-full flex-row justify-between">
<p class="text-base">{{ entry?.year }}</p>
<p class="text-base" :data-cy="`participants-${entry?.year}`">
{{ participantsForYear(entry?.year ?? 0) }} {{ $t("a.Teilnehmer") }}
</p>
<p class="text-base font-bold" :data-cy="`cost-${entry?.year}`">
{{ formatCurrencyChfCentimes(entry?.total_cost) }} CHF
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,4 +1,5 @@
<script setup lang="ts">
import { useChosenProfileMapping } from "@/components/dashboard/composables";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
import { useDashboardPersonsDueDates } from "@/composables";
@ -10,6 +11,7 @@ import { useUserStore } from "@/stores/user";
import type { DashboardPersonsPageMode, StatisticsFilterItem } from "@/types";
import { exportDataAsXls } from "@/utils/export";
import { useRouteQuery } from "@vueuse/router";
import dayjs from "dayjs";
import { useTranslation } from "i18next-vue";
import _ from "lodash";
import log from "loglevel";
@ -36,6 +38,7 @@ const { t } = useTranslation();
const userStore = useUserStore();
const { loading, dashboardPersons } = useDashboardPersonsDueDates(props.mode);
const { CHOSEN_PROFILE_TO_NAME } = useChosenProfileMapping();
const courses = computed(() => {
return [
@ -160,7 +163,7 @@ const roles = computed(() => {
return [
{
id: "",
id: UNFILTERED,
name: `${t("Rolle")}: ${t("a.Alle")}`,
},
...values,
@ -168,6 +171,65 @@ const roles = computed(() => {
});
const selectedRole = ref<MenuItem>(roles.value[0]);
const chosenProfiles = computed(() => {
const values = _(dashboardPersons.value)
.map((cs) => {
return Object.assign({}, cs, {
name: CHOSEN_PROFILE_TO_NAME[cs.chosen_profile],
id: cs.chosen_profile,
});
})
.uniqBy("id")
.orderBy("name")
.value();
return [
{
id: UNFILTERED,
name: `${t("Zulassungsprofil")}: ${t("a.Alle")}`,
},
...values,
];
});
const selectedChosenProfile = ref<MenuItem>(chosenProfiles.value[0]);
const paidYears = computed(() => {
const values = _(dashboardPersons.value)
.filter((cs) => dayjs(cs.paid_datetime).isValid())
.map((cs) => {
const paidYear = dayjs(cs.paid_datetime).format("YYYY");
return Object.assign({}, cs, {
name: paidYear,
id: paidYear,
});
})
.uniqBy("id")
.orderBy("name")
.value();
return [
{
id: UNFILTERED,
name: `${t("Jahr")}: ${t("a.Alle")}`,
},
...values,
];
});
const selectedPaidYear = ref<MenuItem>(paidYears.value[0]);
const selectedPaidYearQuery = useRouteQuery("selectedPaidYear", UNFILTERED, {
mode: "replace",
});
watch(selectedPaidYear, () => {
selectedPaidYearQuery.value = selectedPaidYear.value.id;
});
watch(paidYears, () => {
if (selectedPaidYearQuery.value !== UNFILTERED) {
selectedPaidYear.value =
paidYears.value.find((paidYear) => paidYear.id === selectedPaidYearQuery.value) ||
paidYears.value[0];
}
});
const filteredPersons = computed(() => {
return _.orderBy(
dashboardPersons.value
@ -202,12 +264,25 @@ const filteredPersons = computed(() => {
);
})
.filter((person) => {
if (selectedRole.value.id === "") {
if (selectedRole.value.id === UNFILTERED) {
return true;
}
return person.course_sessions.some(
(cs) => cs.user_role_display === selectedRole.value.id
);
})
.filter((person) => {
if (selectedChosenProfile.value.id === UNFILTERED) {
return true;
}
return person.chosen_profile === selectedChosenProfile.value.id;
})
.filter((person) => {
if (selectedPaidYear.value.id === UNFILTERED) {
return true;
}
const paidYear = dayjs(person.paid_datetime).format("YYYY");
return paidYear == selectedPaidYear.value.id;
}),
["last_name", "first_name"]
);
@ -219,7 +294,9 @@ const filtersVisible = computed(() => {
courseSessions.value.length > 2 ||
regions.value.length > 2 ||
generations.value.length > 2 ||
roles.value.length > 2
roles.value.length > 2 ||
chosenProfiles.value.length > 2 ||
paidYears.value.length > 2
);
});
@ -342,6 +419,22 @@ watch(selectedRegion, () => {
:items="roles"
borderless
></ItDropdownSelect>
<ItDropdownSelect
v-if="chosenProfiles.length > 2"
v-model="selectedChosenProfile"
data-cy="select-chosen-profile"
:items="chosenProfiles"
borderless
></ItDropdownSelect>
<ItDropdownSelect
v-if="paidYears.length > 2"
v-model="selectedPaidYear"
data-cy="select-paid-year"
:items="paidYears"
borderless
></ItDropdownSelect>
</section>
<div
v-for="person in filteredPersons"

View File

@ -73,6 +73,11 @@ const router = createRouter({
path: "/dashboard/due-dates",
component: () => import("@/pages/dashboard/DashboardDueDatesPage.vue"),
},
{
path: "/dashboard/cost/:courseSessionId",
component: () => import("@/pages/dashboard/DashboardCostPage.vue"),
props: true,
},
{
path: "/course/:courseSlug/media",
props: true,

View File

@ -4,6 +4,7 @@ import {
DASHBOARD_COURSE_SESSION_PROGRESS,
DASHBOARD_COURSE_STATISTICS,
DASHBOARD_MENTOR_COMPETENCE_SUMMARY,
TRAINING_RESPONSIBLE_STATISTICS,
} from "@/graphql/queries";
import { itGetCached, itPost } from "@/fetchHelpers";
@ -12,6 +13,7 @@ import type {
CourseProgressType,
CourseStatisticsType,
DashboardConfigType,
TrainingResponsibleStatisticsQuery,
} from "@/gql/graphql";
import type {
DashboardPersonsPageMode,
@ -34,7 +36,8 @@ export type DashboardRoleKeyType =
| "Member"
| "MentorUK"
| "MentorVV"
| "Berufsbildner";
| "Berufsbildner"
| "Ausbildungsverantwortlicher";
export type WidgetType =
| "ProgressWidget"
@ -44,7 +47,8 @@ export type WidgetType =
| "MentorCompetenceWidget"
| "CompetenceCertificateWidget"
| "UKStatisticsWidget"
| "UKBerufsbildnerStatisticsWidget";
| "UKBerufsbildnerStatisticsWidget"
| "TrainingResponsibleStatisticsWidget";
export type DashboardPersonCourseSessionType = {
id: string;
@ -74,10 +78,12 @@ export type DashboardPersonType = {
course_sessions: DashboardPersonCourseSessionType[];
avatar_url: string;
avatar_url_small: string;
chosen_profile: string;
competence_metrics?: {
passed_count: number;
failed_count: number;
};
paid_datetime?: string;
};
export type DashboardCourseConfigType = {
@ -118,6 +124,31 @@ export const fetchStatisticData = async (
}
};
export const fetchTrainingResponsibleStatistics = async (
courseSessionId: string
): Promise<TrainingResponsibleStatisticsQuery | null> => {
try {
const res = await graphqlClient.query(TRAINING_RESPONSIBLE_STATISTICS, {
courseSessionId,
});
if (res.error) {
console.error(
"Error fetching training responsible statistics for course session ID:",
courseSessionId,
res.error
);
}
return res.data || null;
} catch (error) {
console.error(
`Error fetching training responsible statistics for course session ID: ${courseSessionId}`,
error
);
return null;
}
};
export const fetchProgressData = async (
courseId: string
): Promise<CourseProgressType | null> => {

View File

@ -0,0 +1,17 @@
export function formatCurrencyChfCentimes(amount: number | undefined) {
if (!amount) {
return formatCurrencyChf(undefined);
}
return formatCurrencyChf(amount / 100);
}
// 10378.2 => 10'378
export function formatCurrencyChf(amount: number | undefined) {
if (!amount) {
return "?";
}
return new Intl.NumberFormat("de-CH", {
style: "decimal",
maximumFractionDigits: 0,
}).format(amount);
}

View File

@ -0,0 +1,77 @@
import { login } from "../helpers"
function selectDropboxItem(dropboxSelector, item) {
cy.get(dropboxSelector).click()
cy.get(dropboxSelector).contains(item).click()
}
describe("ausbildungsverantwortlicher.cy.js", () => {
it("check data on dashboard", () => {
login("test-ausbildungsverantwortlicher1@example.com", "test")
cy.visit("/")
cy.get('[data-cy="panel-role-key"]').should("contain", "Ausbildungsverantwortlicher")
cy.get('[data-cy="num-dashboard-persons"]').should("contain", "3 Personen")
cy.get('[data-cy="dashboard.stats.trainingResponsible.totalCost"]').should(
"contain",
"649",
)
cy.get('[data-cy="dashboard.stats.trainingResponsible.participantsInCurrentYear"]').should(
"contain",
"2",
)
cy.get('[data-cy="dashboard.stats.trainingResponsible.chart.Allbranche.1"]').should(
'exist'
)
cy.get('[data-cy="dashboard.stats.trainingResponsible.chart.Nichtleben.1"]').should(
'exist'
)
})
it("check cost detail page", () => {
login("test-ausbildungsverantwortlicher1@example.com", "test")
cy.visit("/")
cy.get(
'[data-cy="dashboard.stats.trainingResponsible.cost"] [data-cy="basebox.detailsLink"]',
).click()
cy.get('[data-cy="cost-2024"]').should("have.length", 1)
cy.get('[data-cy="cost-2023"]').should("have.length", 1)
cy.get('[data-cy="cost-2024"]').should("contain", "649 CHF")
cy.get('[data-cy="cost-2023"]').should("contain", "324 CHF")
cy.get('[data-cy="participants-2024"]').should("contain", "2")
cy.get('[data-cy="participants-2023"]').should("contain", "1")
})
it("check participants detail page", () => {
login("test-ausbildungsverantwortlicher1@example.com", "test")
cy.visit("/")
cy.get(
'[data-cy="dashboard.stats.trainingResponsible.participants"] [data-cy="basebox.detailsLink"]',
).click()
// Test paid year filter
cy.get('[data-cy="person"]').should("have.length", 2) // year 2024 is preselected by query
selectDropboxItem('[data-cy="select-paid-year"]', "2023")
cy.get('[data-cy="person"]').should("have.length", 1)
selectDropboxItem('[data-cy="select-paid-year"]', "Jahr: Alle")
cy.get('[data-cy="person"]').should("have.length", 3)
// Test chosen profile filter
selectDropboxItem('[data-cy="select-chosen-profile"]', "Nichtleben")
cy.get('[data-cy="person"]').should("have.length", 1)
selectDropboxItem('[data-cy="select-chosen-profile"]', "Allbranche")
cy.get('[data-cy="person"]').should("have.length", 2)
selectDropboxItem('[data-cy="select-chosen-profile"]', "Alle")
cy.get('[data-cy="person"]').should("have.length", 3)
})
})

View File

@ -0,0 +1,48 @@
import os
import sys
import django
from django.contrib.auth.hashers import make_password
sys.path.append("../server")
os.environ.setdefault("IT_APP_ENVIRONMENT", "local")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.base")
django.setup()
from vbv_lernwelt.course.consts import UK_COURSE_IDS
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.learning_mentor.models import (
AgentParticipantRelation,
AgentParticipantRoleType,
)
def main():
berufsbildner, _ = User.objects.get_or_create(
id="5f984be9-3024-4169-9c7b-c9e827c18fd8"
)
berufsbildner.username = "training-responsible-mobi@example.com"
berufsbildner.email = "training-responsible-mobi@example.com"
berufsbildner.language = "de"
berufsbildner.first_name = "Ausbildungsverantwortlicher"
berufsbildner.last_name = "Mobi"
berufsbildner.password = make_password("test")
berufsbildner.save()
for csu in (
CourseSessionUser.objects.filter(user__username__contains="@mobi")
.filter(course_session__course__configuration__is_uk=False)
.filter(role=CourseSessionUser.Role.MEMBER.value)
.exclude(course_session_id__in=UK_COURSE_IDS)
):
AgentParticipantRelation.objects.get_or_create(
agent=berufsbildner,
participant=csu,
role=AgentParticipantRoleType.BERUFSBILDNER.value,
)
if __name__ == "__main__":
main()

View File

@ -59,8 +59,14 @@ DATABASES = {
default="postgres://postgres@localhost:5432/vbv_lernwelt",
)
}
DATABASES["default"]["ATOMIC_REQUESTS"] = True # noqa F405
DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60) # noqa F405
DATABASES["default"]["ATOMIC_REQUESTS"] = env.bool(
"DATABASE_ATOMIC_REQUESTS", default=True
)
DATABASES["default"]["CONN_MAX_AGE"] = env.int("DATABASE_CONN_MAX_AGE", default=0) # noqa F405
# set on 17.09.2024 https://docs.djangoproject.com/en/4.2/ref/databases/#transaction-pooling-server-side-cursors
DATABASES["default"]["DISABLE_SERVER_SIDE_CURSORS"] = env.bool(
"DATABASE_DISABLE_SERVER_SIDE_CURSORS", default=True
)
# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-DEFAULT_AUTO_FIELD
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"

View File

@ -11,6 +11,9 @@ from vbv_lernwelt.core.models import (
SecurityRequestResponseLog,
)
from vbv_lernwelt.core.utils import pretty_print_json
from vbv_lernwelt.learning_mentor.services import (
create_or_sync_ausbildungsverantwortlicher as create_or_sync_av,
)
from vbv_lernwelt.learning_mentor.services import (
create_or_sync_berufsbildner as create_or_sync_bb,
)
@ -55,6 +58,25 @@ def create_or_sync_berufsbildner(modeladmin, request, queryset):
)
@admin.action(description="Ausbildungsverantwortlicher: Create or Sync")
def create_or_sync_ausbildungsverantwortlicher(modeladmin, request, queryset):
success = []
for user in queryset:
success.append(create_or_sync_av(user))
if all(success):
messages.add_message(
request,
messages.SUCCESS,
"Ausbildungsverantwortlicher erfolgreich erstellt oder synchronisiert",
)
else:
messages.add_message(
request,
messages.ERROR,
"Einige Ausbildungsverantwortliche konnten nicht erstellt oder synchronisiert werden",
)
@admin.register(User)
class UserAdmin(auth_admin.UserAdmin):
fieldsets = (
@ -112,10 +134,12 @@ class UserAdmin(auth_admin.UserAdmin):
"last_name",
"is_active",
"is_superuser",
"organisation",
"sso_id",
]
list_filter = ("is_staff", "is_superuser", "is_active", "groups", "organisation")
search_fields = ["first_name", "last_name", "email", "username", "sso_id"]
actions = [create_or_sync_berufsbildner]
actions = [create_or_sync_berufsbildner, create_or_sync_ausbildungsverantwortlicher]
@admin.register(JobLog)

View File

@ -26,8 +26,10 @@ TEST_STUDENT2_USER_ID = "19c40d94-15cc-4198-aaad-ef707c4b0900"
TEST_STUDENT3_USER_ID = "bcf94dba-53bc-474b-a22d-e4af39aa042b"
TEST_MENTOR1_USER_ID = "d1f5f5a9-5b0a-4e1a-9e1a-9e9b5b5e1b1b"
TEST_BERUFSBILDNER1_USER_ID = "bb83dde0-27e7-4859-8acb-a323025d712c"
TEST_AUSBILDUNGSVERANTWORTLICHER1_USER_ID = "ffeedde0-27e7-ff59-8aff-a3230ffd712c"
TEST_STUDENT1_VV_USER_ID = "5ff59857-8de5-415e-a387-4449f9a0337a"
TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID = "7e8ebf0b-e6e2-4022-88f4-6e663ba0a9db"
TEST_STUDENT3_VV_USER_ID = "ba8ebf0b-9de5-215e-a387-4449f9aa337b"
TEST_USER_EMPTY_ID = "daecbabe-4ab9-4edf-a71f-4119042ccb02"
TEST_USER_DATATRANS_HANNA_ID = "6bec1a0d-f852-47aa-a4de-072df6e07ad1"

View File

@ -6,6 +6,7 @@ from environs import Env
from vbv_lernwelt.core.constants import (
ADMIN_USER_ID,
TEST_AUSBILDUNGSVERANTWORTLICHER1_USER_ID,
TEST_BERUFSBILDNER1_USER_ID,
TEST_MENTOR1_USER_ID,
TEST_STUDENT1_USER_ID,
@ -13,6 +14,7 @@ from vbv_lernwelt.core.constants import (
TEST_STUDENT2_USER_ID,
TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID,
TEST_STUDENT3_USER_ID,
TEST_STUDENT3_VV_USER_ID,
TEST_SUPERVISOR1_USER_ID,
TEST_TRAINER1_USER_ID,
TEST_TRAINER2_USER_ID,
@ -254,6 +256,12 @@ def create_default_users(default_password="test", set_avatar=False):
first_name="Viktor",
last_name="Vollgas",
)
_create_student_user(
id=TEST_STUDENT3_VV_USER_ID,
email="student-vv3@eiger-versicherungen.ch",
first_name="Vladi",
last_name="Volodemir",
)
_create_student_user(
email="patrizia.huggel@eiger-versicherungen.ch",
first_name="Patrizia",
@ -419,6 +427,15 @@ def create_default_users(default_password="test", set_avatar=False):
language="de",
avatar_image="uk1.patrizia.huggel.jpg",
)
_create_user(
_id=TEST_AUSBILDUNGSVERANTWORTLICHER1_USER_ID,
email="test-ausbildungsverantwortlicher1@example.com",
first_name="Bruno",
last_name="Banani-Ausbildungsverantwortlicher",
password=default_password,
language="de",
avatar_image="uk1.patrizia.huggel.jpg",
)
_create_student_user(
id=TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID,
email="test-student-and-mentor2@example.com",

View File

@ -108,7 +108,7 @@ def delete_cs_data(cs: CourseSession):
CourseSessionEdoniqTest.objects.filter(course_session=cs).delete()
CourseSessionUser.objects.filter(course_session=cs).delete()
AgentParticipantRelation.objects.filter(course_session=cs).delete()
AgentParticipantRelation.objects.filter(participant__course_session=cs).delete()
else:
logger.info("no_course_session_found", import_id=cs.import_id)

View File

@ -66,6 +66,7 @@ class CourseSessionUserAdmin(admin.ModelAdmin):
"circles",
"optional_attendance",
"user_sso_id",
"user_organisation",
# "created_at",
# "updated_at",
]
@ -79,6 +80,7 @@ class CourseSessionUserAdmin(admin.ModelAdmin):
"role",
"course_session",
"optional_attendance",
"user__organisation",
]
raw_id_fields = [
"user",
@ -102,6 +104,12 @@ class CourseSessionUserAdmin(admin.ModelAdmin):
user_sso_id.short_description = "SSO ID"
user_sso_id.admin_order_field = "user__sso_id"
def user_organisation(self, obj):
return obj.user.organisation
user_organisation.short_description = "Organisation"
user_organisation.admin_order_field = "user__organisation"
def circles(self, obj):
return ", ".join([c.title for c in obj.expert.all()])

View File

@ -45,7 +45,12 @@ from vbv_lernwelt.competence.create_vv_new_competence_profile import (
create_vv_new_competence_profile,
)
from vbv_lernwelt.competence.models import PerformanceCriteria
from vbv_lernwelt.core.constants import TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID
from vbv_lernwelt.core.constants import (
TEST_AUSBILDUNGSVERANTWORTLICHER1_USER_ID,
TEST_STUDENT1_VV_USER_ID,
TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID,
TEST_STUDENT3_VV_USER_ID,
)
from vbv_lernwelt.core.create_default_users import default_users
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.consts import (
@ -92,6 +97,14 @@ from vbv_lernwelt.importer.services import (
import_students_from_excel,
import_trainers_from_excel_for_training,
)
from vbv_lernwelt.learning_mentor.models import (
AgentParticipantRelation,
AgentParticipantRoleType,
)
from vbv_lernwelt.learnpath.consts import (
COURSE_PROFILE_NICHTLEBEN_CODE,
COURSE_PROFILE_NICHTLEBEN_ID,
)
from vbv_lernwelt.learnpath.create_vv_new_learning_path import (
create_vv_motorfahrzeug_pruefung_learning_path,
create_vv_new_learning_path,
@ -100,6 +113,7 @@ from vbv_lernwelt.learnpath.create_vv_new_learning_path import (
from vbv_lernwelt.learnpath.creators import assign_circles_to_profiles
from vbv_lernwelt.learnpath.models import (
Circle,
CourseProfile,
LearningContent,
LearningContentAssignment,
LearningContentAttendanceCourse,
@ -113,6 +127,7 @@ from vbv_lernwelt.media_files.create_default_images import create_default_images
from vbv_lernwelt.media_library.create_default_media_library import (
create_default_media_library,
)
from vbv_lernwelt.shop.tests.factories import CheckoutInformationFactory
ADMIN_EMAILS = ["info@iterativ.ch", "admin"]
@ -239,13 +254,21 @@ def create_versicherungsvermittlerin_course(
student_1_csu = CourseSessionUser.objects.create(
course_session=cs,
user=User.objects.get(username="student-vv@eiger-versicherungen.ch"),
user=User.objects.get(id=TEST_STUDENT1_VV_USER_ID),
)
mentor_and_student_2_learning_csu = CourseSessionUser.objects.create(
course_session=cs,
user=User.objects.get(id=TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID),
role=CourseSessionUser.Role.MEMBER,
)
student_3_csu = CourseSessionUser.objects.create(
course_session=cs,
user=User.objects.get(id=TEST_STUDENT3_VV_USER_ID),
chosen_profile=CourseProfile.objects.get_or_create(
id=COURSE_PROFILE_NICHTLEBEN_ID,
code=COURSE_PROFILE_NICHTLEBEN_CODE,
)[0],
)
CourseSessionUser.objects.create(
course_session=cs,
@ -270,6 +293,37 @@ def create_versicherungsvermittlerin_course(
course_session=cs,
user=User.objects.get(email=admin_email),
)
# Ausbildungsverantwortlicher
AgentParticipantRelation.objects.create(
agent=User.objects.get(id=TEST_AUSBILDUNGSVERANTWORTLICHER1_USER_ID),
participant=student_1_csu,
role=AgentParticipantRoleType.BERUFSBILDNER.value,
)
AgentParticipantRelation.objects.create(
agent=User.objects.get(id=TEST_AUSBILDUNGSVERANTWORTLICHER1_USER_ID),
participant=mentor_and_student_2_learning_csu,
role=AgentParticipantRoleType.BERUFSBILDNER.value,
)
AgentParticipantRelation.objects.create(
agent=User.objects.get(id=TEST_AUSBILDUNGSVERANTWORTLICHER1_USER_ID),
participant=student_3_csu,
role=AgentParticipantRoleType.BERUFSBILDNER.value,
)
checkout = CheckoutInformationFactory(
user=User.objects.get(id=TEST_STUDENT1_VV_USER_ID),
)
checkout.created_at = datetime(2024, 9, 1, tzinfo=timezone.utc)
checkout.save()
checkout = CheckoutInformationFactory(
user=User.objects.get(id=TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID),
)
checkout.created_at = datetime(2023, 8, 1, tzinfo=timezone.utc)
checkout.save()
checkout = CheckoutInformationFactory(
user=User.objects.get(id=TEST_STUDENT3_VV_USER_ID),
)
checkout.created_at = datetime(2024, 9, 1, tzinfo=timezone.utc)
checkout.save()
def create_versicherungsvermittlerin_pruefung_course(

View File

@ -1,4 +1,5 @@
import uuid
from datetime import datetime
from enum import Enum
from django.db import models
@ -13,6 +14,7 @@ from vbv_lernwelt.core.model_utils import find_available_slug
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.serializer_helpers import get_course_serializer_class
from vbv_lernwelt.files.models import UploadFile
from vbv_lernwelt.shop.models import CheckoutState
class CircleContactType(Enum):
@ -301,6 +303,18 @@ class CourseSessionUser(models.Model):
]
ordering = ["user__last_name", "user__first_name", "user__email"]
@property
def paid_datetime(self) -> datetime | None:
"""
Returns the datetime when the user paid for the course session
"""
checkout = self.user.checkout_informations.filter(
state=CheckoutState.PAID
).order_by("created_at")
if checkout:
return checkout.first().created_at
return None
def __str__(self):
return f"{self.user} ({self.course_session.title})"

View File

@ -19,6 +19,7 @@ from vbv_lernwelt.dashboard.graphql.types.dashboard import (
DashboardType,
ProgressDashboardAssignmentType,
ProgressDashboardCompetenceType,
TrainingResponsibleStatisticsType,
)
from vbv_lernwelt.iam.permissions import (
can_view_course_session,
@ -69,6 +70,11 @@ class DashboardQuery(graphene.ObjectType):
agent_role=graphene.String(required=True),
)
training_responsible_statistics = graphene.Field(
TrainingResponsibleStatisticsType,
course_session_id=graphene.ID(required=True),
)
course_progress = graphene.Field(
CourseProgressType, course_id=graphene.ID(required=True)
)
@ -124,6 +130,13 @@ class DashboardQuery(graphene.ObjectType):
return _agent_course_statistics(user, course_id, role=agent_role)
@staticmethod
def resolve_training_responsible_statistics(root, info, course_session_id: str): # noqa
return TrainingResponsibleStatisticsType(
_id=course_session_id, # noqa
course_session_id=course_session_id, # noqa
)
def resolve_dashboard_config(root, info): # noqa
user = info.context.user

View File

@ -1,7 +1,14 @@
from collections import defaultdict
from itertools import groupby
import graphene
from django.db.models.functions import ExtractYear
from graphene import Enum
from vbv_lernwelt.course.graphql.types import CourseConfigurationObjectType
from vbv_lernwelt.course.graphql.types import (
CourseConfigurationObjectType,
CourseSessionUserType,
)
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.dashboard.graphql.types.assignment import (
AssignmentsStatisticsType,
@ -20,7 +27,12 @@ from vbv_lernwelt.dashboard.graphql.types.feedback import (
FeedbackStatisticsResponsesType,
feedback_responses,
)
from vbv_lernwelt.learning_mentor.models import AgentParticipantRelation
from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState
from vbv_lernwelt.shop.views import (
COURSE_SESSION_ID_TO_PRODUCT_SKU,
)
class StatisticsCourseSessionDataType(graphene.ObjectType):
@ -242,3 +254,81 @@ class CourseStatisticsType(BaseStatisticsType):
participant_count=participant_count, # noqa
expert_count=expert_count, # noqa
)
class TrainingsResponsibleCostForYear(graphene.ObjectType):
_id = graphene.ID(required=True)
year = graphene.Int(required=True)
# In centimes CHF
total_cost = graphene.Int(required=True)
class TrainingsResponsibleParticipantsForYear(graphene.ObjectType):
_id = graphene.ID(required=True)
year = graphene.Int(required=True)
participants = graphene.List(CourseSessionUserType, required=True)
class TrainingResponsibleStatisticsType(graphene.ObjectType):
_id = graphene.ID(required=True)
course_session_id = graphene.ID(required=True)
cost_per_year = graphene.List(TrainingsResponsibleCostForYear, required=True)
participants_per_year = graphene.List(
TrainingsResponsibleParticipantsForYear, required=True
)
@staticmethod
def resolve_cost_per_year(root, info): # noqa
user = info.context.user
relations_qs = AgentParticipantRelation.objects.filter(
agent=user,
participant__course_session=root.course_session_id,
)
users = relations_qs.values_list("participant__user", flat=True)
sku = COURSE_SESSION_ID_TO_PRODUCT_SKU.get(int(root.course_session_id))
checkout_information = CheckoutInformation.objects.filter(
state=CheckoutState.PAID,
product_sku=sku,
user__in=users,
)
grouped_checkouts = groupby(
sorted(checkout_information, key=lambda x: x.created_at.year, reverse=True),
key=lambda x: x.created_at.year,
)
return [
TrainingsResponsibleCostForYear(
_id=f"{root.course_session_id} {year}",
year=year,
total_cost=sum(c.product_price for c in checkouts),
)
for year, checkouts in grouped_checkouts
]
@staticmethod
def resolve_participants_per_year(root, info):
user = info.context.user
course_session_users = (
CourseSessionUser.objects.filter(
agentparticipantrelation__agent=user,
agentparticipantrelation__participant__course_session=root.course_session_id,
)
.annotate(
checkout_year=ExtractYear("user__checkout_informations__created_at")
)
.select_related("user")
)
grouped_course_session_users = defaultdict(list)
for csu in course_session_users:
if csu.checkout_year:
grouped_course_session_users[csu.checkout_year].append(csu)
return [
TrainingsResponsibleParticipantsForYear(
_id=f"{root.course_session_id} {year}",
year=year,
participants=participants,
)
for year, participants in grouped_course_session_users.items()
]

View File

@ -98,7 +98,17 @@ def create_course_session_dict(course_session_object, my_role, user_role):
def create_person_list_with_roles(
user, course_session_ids=None, include_private_data=False
):
def create_user_dict(user_object):
def create_csu_dict(csu: CourseSessionUser):
user_data = create_user_dict(csu.user)
user_data["chosen_profile"] = (
csu.chosen_profile.code if csu.chosen_profile else "all"
)
user_data["paid_datetime"] = (
csu.paid_datetime.isoformat() if csu.paid_datetime else None
)
return user_data
def create_user_dict(user_object: User):
user_data = {
"user_id": user_object.id,
"first_name": user_object.first_name,
@ -130,9 +140,7 @@ def create_person_list_with_roles(
).select_related("user")
my_role = user_role(cs.roles)
for csu in course_session_users:
person_data = result_persons.get(
csu.user.id, create_user_dict(csu.user)
)
person_data = result_persons.get(csu.user.id, create_csu_dict(csu))
person_data["course_sessions"].append(
create_course_session_dict(cs, my_role, csu.role)
)
@ -146,7 +154,7 @@ def create_person_list_with_roles(
participant_user = relation.participant.user
if participant_user.id not in result_persons:
person_data = create_user_dict(participant_user)
person_data = create_csu_dict(relation.participant)
person_data["course_sessions"] = [course_session_entry]
result_persons[participant_user.id] = person_data
else:

View File

@ -62,6 +62,7 @@ class WidgetType(Enum):
COMPETENCE_CERTIFICATE_WIDGET = "CompetenceCertificateWidget"
UK_STATISTICS_WIDGET = "UKStatisticsWidget"
UK_BERUFSBILDNER_STATISTICS_WIDGET = "UKBerufsbildnerStatisticsWidget"
TRAINING_RESPONSIBLE_STATISTICS_WIDGET = "TrainingResponsibleStatisticsWidget"
class RoleKeyType(Enum):
@ -71,6 +72,7 @@ class RoleKeyType(Enum):
SUPERVISOR = "Supervisor"
TRAINER = "Trainer"
BERUFSBILDNER = "Berufsbildner"
TRAINING_RESPONSIBLE = "Ausbildungsverantwortlicher"
UNKNOWN_ROLE_KEY = "UnknownRoleKey"
@ -210,6 +212,8 @@ def get_widgets_for_course(
if "BERUFSBILDNER" in relation_roles:
if is_uk:
widgets.append(WidgetType.UK_BERUFSBILDNER_STATISTICS_WIDGET.value)
if is_vv:
widgets.append(WidgetType.TRAINING_RESPONSIBLE_STATISTICS_WIDGET.value)
return widgets
@ -237,6 +241,8 @@ def get_relevant_role_key(
elif "BERUFSBILDNER" in relation_roles:
if is_uk:
return RoleKeyType.BERUFSBILDNER
elif is_vv:
return RoleKeyType.TRAINING_RESPONSIBLE
return RoleKeyType.UNKNOWN_ROLE_KEY

View File

@ -1,7 +1,12 @@
import structlog
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.consts import COURSE_UK, COURSE_UK_FR, COURSE_UK_IT
from vbv_lernwelt.course.consts import (
COURSE_UK,
COURSE_UK_FR,
COURSE_UK_IT,
VV_COURSE_IDS,
)
from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.learning_mentor.models import (
AgentParticipantRelation,
@ -14,21 +19,6 @@ UK_COURSES = [COURSE_UK, COURSE_UK_FR, COURSE_UK_IT]
def create_or_sync_berufsbildner(berufsbildner: User) -> bool:
logger.info(
"Creating or syncing berufsbildner",
berufsbildner=berufsbildner,
org=berufsbildner.organisation.name_de,
)
# check if it is a valid organisation
if berufsbildner.organisation and berufsbildner.organisation.organisation_id < 4:
logger.error("Invalid organisation", org=berufsbildner.organisation)
return False
# get existing connections
existing_members = set(berufsbildner.agentparticipantrelation_set.all())
# gather new relations
new_members = set(
CourseSessionUser.objects.filter(user__organisation=berufsbildner.organisation)
.filter(course_session__course__configuration__is_uk=True)
@ -36,12 +26,45 @@ def create_or_sync_berufsbildner(berufsbildner: User) -> bool:
.filter(course_session__course_id__in=UK_COURSES)
.exclude(course_session_id__in=[4, 5, 6])
)
return create_or_sync_learning_mentor(berufsbildner, new_members)
def create_or_sync_ausbildungsverantwortlicher(
ausbildungsverantwortlicher: User,
) -> bool:
new_members = set(
CourseSessionUser.objects.filter(
user__organisation=ausbildungsverantwortlicher.organisation
)
.filter(course_session__course__configuration__is_uk=False)
.filter(role=CourseSessionUser.Role.MEMBER.value)
.filter(course_session__course_id__in=VV_COURSE_IDS)
)
return create_or_sync_learning_mentor(ausbildungsverantwortlicher, new_members)
def create_or_sync_learning_mentor(
agent: User, new_members: set[CourseSessionUser]
) -> bool:
logger.info(
"Creating or syncing berufsbildner",
berufsbildner=agent,
org=agent.organisation.name_de,
)
# check if it is a valid organisation
if agent.organisation and agent.organisation.organisation_id < 4:
logger.error("Invalid organisation", org=agent.organisation)
return False
# get existing connections
existing_members = set(agent.agentparticipantrelation_set.all())
# add new relations that are not in existing relations
for csu in new_members:
if csu not in existing_members:
AgentParticipantRelation.objects.get_or_create(
agent=berufsbildner,
agent=agent,
participant=csu,
role=AgentParticipantRoleType.BERUFSBILDNER.value,
)

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.13 on 2024-09-17 14:00
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("shop", "0019_alter_checkoutinformation_refno2"),
]
operations = [
migrations.AlterField(
model_name="checkoutinformation",
name="user",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="checkout_informations",
to=settings.AUTH_USER_MODEL,
),
),
]

View File

@ -43,7 +43,9 @@ class CheckoutInformation(models.Model):
(INVOICE_ADDRESS_ORGANISATION, "Organisation"),
)
user = models.ForeignKey("core.User", on_delete=models.PROTECT)
user = models.ForeignKey(
"core.User", on_delete=models.PROTECT, related_name="checkout_informations"
)
product_sku = models.CharField(max_length=255)
product_name = models.CharField(max_length=255)

View File

@ -37,6 +37,10 @@ PRODUCT_SKU_TO_COURSE_SESSION_ID = {
VV_IT_PRODUCT_SKU: 3, # vv-it
}
COURSE_SESSION_ID_TO_PRODUCT_SKU = {
id: sku for sku, id in PRODUCT_SKU_TO_COURSE_SESSION_ID.items()
}
@api_view(["POST"])
def transaction_webhook(request):