Initial implementation

This commit is contained in:
Elia Bieri 2024-09-10 17:00:21 +02:00
parent 6f3dac2e97
commit bd95776ec7
22 changed files with 615 additions and 109 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

@ -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,72 @@
<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 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 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
);
});
</script>
<template>
<div class="flex flex-col flex-wrap items-stretch md:flex-row">
<BaseBox
:details-link="`/dashboard/cost/${courseSessionId}`"
data-cy="dashboard.mentor.competenceSummary"
class="w-1/2"
>
<template #title>{{ $t("Kosten in") }} {{ new Date().getFullYear() }}</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">
{{ formatCurrencyChfCentimes(totalCostInCurrentYear) }}
</span>
<span class="ml-4">CHF</span>
</div>
</div>
</template>
</BaseBox>
<BaseBox
:details-link="`/dashboard/persons?course=${courseId}`"
data-cy="dashboard.mentor.competenceSummary"
>
<template #title>{{ $t("Teilnehmer im 2024") }}</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"
>
<span>{{ attendanceCountInCurrentYear }}</span>
</div>
<p class="ml-3 mt-0 leading-[74px]">
{{ $t("Teilnehmer") }}
</p>
</div>
</template>
</BaseBox>
</div>
</template>

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: [CostForYear]!
participants_per_year: [ParticipantsForYear]!
}
type ProgressDashboardCompetenceType {
type CostForYear {
_id: ID!
total_count: Int!
success_count: Int!
fail_count: Int!
year: Int!
total_cost: Int!
}
type ProgressDashboardAssignmentType {
type ParticipantsForYear {
_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

@ -26,6 +26,7 @@ export const CompetenceRecordStatisticsType = "CompetenceRecordStatisticsType";
export const CompetencesStatisticsType = "CompetencesStatisticsType";
export const ContentDocumentObjectType = "ContentDocumentObjectType";
export const CoreUserLanguageChoices = "CoreUserLanguageChoices";
export const CostForYear = "CostForYear";
export const CourseConfigurationObjectType = "CourseConfigurationObjectType";
export const CourseObjectType = "CourseObjectType";
export const CoursePageInterface = "CoursePageInterface";
@ -74,6 +75,7 @@ export const LearningSequenceObjectType = "LearningSequenceObjectType";
export const LearningUnitObjectType = "LearningUnitObjectType";
export const LearnpathLearningContentAssignmentAssignmentTypeChoices = "LearnpathLearningContentAssignmentAssignmentTypeChoices";
export const Mutation = "Mutation";
export const ParticipantsForYear = "ParticipantsForYear";
export const PerformanceCriteriaObjectType = "PerformanceCriteriaObjectType";
export const PresenceRecordStatisticsType = "PresenceRecordStatisticsType";
export const ProgressDashboardAssignmentType = "ProgressDashboardAssignmentType";
@ -86,6 +88,7 @@ export const StatisticsCourseSessionPropertiesType = "StatisticsCourseSessionPro
export const StatisticsCourseSessionsSelectionMetricType = "StatisticsCourseSessionsSelectionMetricType";
export const String = "String";
export const TopicObjectType = "TopicObjectType";
export const TrainingResponsibleStatisticsType = "TrainingResponsibleStatisticsType";
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,65 @@
<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"
data-cy="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">
{{ participantsForYear(entry?.year ?? 0) }} {{ $t("a.Teilnehmer") }}
</p>
<p class="text-base font-bold">
{{ formatCurrencyChfCentimes(entry?.total_cost) }} CHF
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -168,6 +168,29 @@ const roles = computed(() => {
});
const selectedRole = ref<MenuItem>(roles.value[0]);
const chosenProfiles = computed(() => {
const values = _(dashboardPersons.value)
.map((cs) => {
return Object.assign({}, cs, {
name: cs.chosen_profile,
id: cs.chosen_profile,
});
})
.filter((cs) => cs.chosen_profile !== "all")
.uniqBy("id")
.orderBy("name")
.value();
return [
{
id: "all",
name: `${t("Zulassungsprofil")}: ${t("a.Alle")}`,
},
...values,
];
});
const selectedChosenProfile = ref<MenuItem>(chosenProfiles.value[0]);
const filteredPersons = computed(() => {
return _.orderBy(
dashboardPersons.value
@ -208,6 +231,12 @@ const filteredPersons = computed(() => {
return person.course_sessions.some(
(cs) => cs.user_role_display === selectedRole.value.id
);
})
.filter((person) => {
if (selectedChosenProfile.value.id === "") {
return true;
}
return person.chosen_profile === selectedChosenProfile.value.id;
}),
["last_name", "first_name"]
);
@ -219,7 +248,8 @@ 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
);
});
@ -342,6 +372,14 @@ watch(selectedRegion, () => {
:items="roles"
borderless
></ItDropdownSelect>
<ItDropdownSelect
v-if="chosenProfiles.length > 2"
v-model="selectedChosenProfile"
data-cy="select-chosen-profile"
:items="chosenProfiles"
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,8 @@ import type {
CourseProgressType,
CourseStatisticsType,
DashboardConfigType,
TrainingResponsibleStatisticsQuery,
TrainingResponsibleStatisticsType,
} from "@/gql/graphql";
import type {
DashboardPersonsPageMode,
@ -34,7 +37,8 @@ export type DashboardRoleKeyType =
| "Member"
| "MentorUK"
| "MentorVV"
| "Berufsbildner";
| "Berufsbildner"
| "Ausbildungsverantwortlicher";
export type WidgetType =
| "ProgressWidget"
@ -44,7 +48,8 @@ export type WidgetType =
| "MentorCompetenceWidget"
| "CompetenceCertificateWidget"
| "UKStatisticsWidget"
| "UKBerufsbildnerStatisticsWidget";
| "UKBerufsbildnerStatisticsWidget"
| "TrainingResponsibleStatisticsWidget";
export type DashboardPersonCourseSessionType = {
id: string;
@ -78,6 +83,7 @@ export type DashboardPersonType = {
passed_count: number;
failed_count: number;
};
chosen_profile: 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,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

@ -22,17 +22,17 @@ def main():
berufsbildner, _ = User.objects.get_or_create(
id="5f984be9-3024-4169-9c7b-c9e827c18fd8"
)
berufsbildner.username = "berufsbildner-mobi@example.com"
berufsbildner.email = "berufsbildner-mobi@example.com"
berufsbildner.username = "training-responsible-mobi@example.com"
berufsbildner.email = "training-responsible-mobi@example.com"
berufsbildner.language = "de"
berufsbildner.first_name = "Berufsbildner"
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=True)
.filter(course_session__course__configuration__is_uk=False)
.filter(role=CourseSessionUser.Role.MEMBER.value)
.exclude(course_session_id__in=[4, 5, 6])
):

View File

@ -26,6 +26,7 @@ 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_LERNBEGLEITER1_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_USER_EMPTY_ID = "daecbabe-4ab9-4edf-a71f-4119042ccb02"

View File

@ -7,6 +7,7 @@ from environs import Env
from vbv_lernwelt.core.constants import (
ADMIN_USER_ID,
TEST_BERUFSBILDNER1_USER_ID,
TEST_LERNBEGLEITER1_USER_ID,
TEST_MENTOR1_USER_ID,
TEST_STUDENT1_USER_ID,
TEST_STUDENT1_VV_USER_ID,
@ -419,6 +420,15 @@ def create_default_users(default_password="test", set_avatar=False):
language="de",
avatar_image="uk1.patrizia.huggel.jpg",
)
_create_user(
_id=TEST_LERNBEGLEITER1_USER_ID,
email="test-lernbegleiter1@example.com",
first_name="Bruno",
last_name="Banani-Lernbegleiter",
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

@ -1,3 +1,4 @@
from hashlib import md5
from typing import Dict, List, Set, Tuple
import graphene
@ -19,6 +20,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 +71,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 +131,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,13 @@
from datetime import datetime
from itertools import groupby
import graphene
from graphene import Enum
from vbv_lernwelt.course.graphql.types import CourseConfigurationObjectType
from vbv_lernwelt.core.admin import User
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 +26,13 @@ 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,
PRODUCT_SKU_TO_COURSE_SESSION_ID,
)
class StatisticsCourseSessionDataType(graphene.ObjectType):
@ -242,3 +254,75 @@ class CourseStatisticsType(BaseStatisticsType):
participant_count=participant_count, # noqa
expert_count=expert_count, # noqa
)
class CostForYear(graphene.ObjectType):
_id = graphene.ID(required=True)
year = graphene.Int(required=True)
# In centimes CHF
total_cost = graphene.Int(required=True)
class ParticipantsForYear(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(CostForYear, required=True)
participants_per_year = graphene.List(ParticipantsForYear, 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),
key=lambda x: x.created_at.year,
)
return [
CostForYear(
_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,
)
grouped_course_session_users = groupby(
sorted(course_session_users, key=lambda x: x.created_at.year),
key=lambda x: x.created_at.year,
)
return [
ParticipantsForYear(
_id=f"{root.course_session_id} {year}",
year=year,
participants=list(c),
)
for year, c in grouped_course_session_users
]

View File

@ -98,7 +98,8 @@ 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_user_dict(csu: CourseSessionUser):
user_object = csu.user
user_data = {
"user_id": user_object.id,
"first_name": user_object.first_name,
@ -107,6 +108,7 @@ def create_person_list_with_roles(
"avatar_url_small": user_object.avatar_url_small,
"avatar_url": user_object.avatar_url,
"course_sessions": [],
"chosen_profile": csu.chosen_profile.code if csu.chosen_profile else "all",
}
if include_private_data:
user_data["phone_number"] = user_object.phone_number
@ -130,9 +132,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_user_dict(csu))
person_data["course_sessions"].append(
create_course_session_dict(cs, my_role, csu.role)
)
@ -146,7 +146,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_user_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

@ -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):