Merged develop into feature/VBV-704-eine-note-im-kn-für-ük

This commit is contained in:
Elia Bieri 2024-07-31 12:54:26 +00:00
commit 6f2b437a5c
70 changed files with 1768 additions and 590 deletions

View File

@ -97,6 +97,7 @@ js-linting: &js-linting
default-steps: &default-steps
- parallel:
- step: *e2e
- step: *e2e
- step: *e2e
- step: *python-tests

View File

@ -93,8 +93,12 @@ def main(app_name, image_name, environment_file):
"AWS_S3_SECRET_ACCESS_KEY": env.str("AWS_S3_SECRET_ACCESS_KEY", ""),
"AWS_S3_REGION_NAME": "eu-central-1",
"AWS_STORAGE_BUCKET_NAME": "myvbv-dev.iterativ.ch",
"DATATRANS_HMAC_KEY": env.str("DATATRANS_HMAC_KEY", ""),
"DATATRANS_BASIC_AUTH_KEY": env.str("DATATRANS_BASIC_AUTH_KEY", ""),
"DATATRANS_HMAC_KEY": env.str("PIPELINES_DATATRANS_HMAC_KEY", ""),
"DATATRANS_BASIC_AUTH_KEY": env.str(
"PIPELINES_DATATRANS_BASIC_AUTH_KEY", ""
),
"DATATRANS_API_ENDPOINT": "https://api.sandbox.datatrans.com",
"DATATRANS_PAY_URL": "https://pay.sandbox.datatrans.com",
"FILE_UPLOAD_STORAGE": "s3",
"IT_DJANGO_DEBUG": "false",
"IT_SERVE_VUE": "false",

View File

@ -6,6 +6,7 @@ import type { CourseStatisticsType } from "@/gql/graphql";
import AssignmentSummaryBox from "@/components/dashboard/AssignmentSummaryBox.vue";
import FeedbackSummaryBox from "@/components/dashboard/FeedbackSummaryBox.vue";
import CompetenceSummaryBox from "@/components/dashboard/CompetenceSummaryBox.vue";
import LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
const props = defineProps<{
courseId: string;
@ -95,4 +96,7 @@ onMounted(async () => {
/>
</div>
</div>
<div v-else class="flex w-full flex-row justify-center">
<LoadingSpinner />
</div>
</template>

View File

@ -3,6 +3,7 @@ import LearningPathCircle from "@/pages/learningPath/learningPathPage/LearningPa
import { calculateCircleSectorData } from "@/pages/learningPath/learningPathPage/utils";
import { computed } from "vue";
import { useCourseCircleProgress, useCourseDataWithCompletion } from "@/composables";
import LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
export type DiagramType = "horizontal" | "horizontalSmall" | "singleSmall";
@ -54,23 +55,25 @@ const { inProgressCirclesCount, circlesCount } = useCourseCircleProgress(
<template>
<div>
<h4
v-if="diagramType === 'horizontal' && circles.length > 0"
class="mb-4 font-bold"
>
{{
$t("learningPathPage.progressText", {
inProgressCount: inProgressCirclesCount,
allCount: circlesCount,
})
}}
</h4>
<div :class="wrapperClasses">
<LearningPathCircle
v-for="circle in circles"
:key="circle.id"
:sectors="calculateCircleSectorData(circle)"
></LearningPathCircle>
<div v-if="circlesCount > 0">
<h4 v-if="diagramType === 'horizontal'" class="mb-4 font-bold">
{{
$t("learningPathPage.progressText", {
inProgressCount: inProgressCirclesCount,
allCount: circlesCount,
})
}}
</h4>
<div :class="wrapperClasses">
<LearningPathCircle
v-for="circle in circles"
:key="circle.id"
:sectors="calculateCircleSectorData(circle)"
></LearningPathCircle>
</div>
</div>
<div v-else class="flex justify-center">
<LoadingSpinner />
</div>
</div>
</template>

View File

@ -34,7 +34,7 @@ const orgAddress = computed({
for="company-name"
class="block text-sm font-medium leading-6 text-gray-900"
>
{{ $t("a.Name") }}
{{ $t("a.Firmenname") }}
</label>
<div class="mt-2">
<input

View File

@ -1,10 +1,9 @@
<script setup lang="ts">
import { computed } from "vue";
import { useEntities } from "@/services/entities";
import VueDatePicker from "@vuepic/vue-datepicker";
import "@vuepic/vue-datepicker/dist/main.css";
import { t } from "i18next";
import { useUserStore } from "@/stores/user";
import ItDatePicker from "@/components/ui/ItDatePicker.vue";
const props = defineProps<{
modelValue: {
@ -25,20 +24,12 @@ const props = defineProps<{
const emit = defineEmits(["update:modelValue"]);
const { countries } = useEntities();
const userStore = useUserStore();
const paymentMethods = [
{ value: "credit_card", label: t("a.Debit-/Kreditkarte/Twint") },
{ value: "cembra_byjuno", label: t("a.Rechnung") },
];
// TODO: remove after cembra is ready for production
const appEnv = import.meta.env.VITE_APP_ENVIRONMENT || "local";
if (appEnv.startsWith("prod")) {
paymentMethods.splice(1, 1);
}
// END TODO
const address = computed({
get() {
return props.modelValue;
@ -234,40 +225,8 @@ const address = computed({
{{ $t("a.Geburtsdatum") }}
</label>
<div class="mt-2">
<VueDatePicker
v-model="address.birth_date"
format="dd.MM.yyyy"
no-today
model-type="yyyy-MM-dd"
name="birth-date"
max-date="2007-01-01"
prevent-min-max-navigation
required
:enable-time-picker="false"
text-input
placeholder="15.06.1982"
start-date="1982-01-01"
:locale="userStore.language"
:cancel-text="$t('a.Abbrechen')"
:select-text="$t('a.Auswählen')"
></VueDatePicker>
<ItDatePicker v-model="address.birth_date"></ItDatePicker>
</div>
</div>
</div>
</template>
<style>
/* Theming for date picker */
.dp__theme_light {
--dp-text-color: #585f63;
--dp-primary-color: #41b5fa;
--dp-border-color-focus: #3d6dcc;
}
:root {
--dp-font-family: "Buenos Aires" sans-serif;
--dp-border-radius: none;
--dp-font-size: 0.875rem;
--dp-cell-border-radius: none;
}
</style>

View File

@ -3,6 +3,8 @@ import { useEntities } from "@/services/entities";
import AvatarImage from "@/components/ui/AvatarImage.vue";
import { ref } from "vue";
import { type User, useUserStore } from "@/stores/user";
import ItDatePicker from "@/components/ui/ItDatePicker.vue";
import { normalizeSwissPhoneNumber } from "@/utils/phone";
const emit = defineEmits(["cancel", "save"]);
@ -21,7 +23,12 @@ const formData = ref({
postal_code: user.postal_code,
city: user.city,
country_code: user.country?.country_code,
phone_number: user.phone_number,
birth_date: user.birth_date,
organisation: user.organisation,
organisation_detail_name: user.organisation_detail_name,
organisation_street: user.organisation_street,
organisation_street_number: user.organisation_street_number,
organisation_postal_code: user.organisation_postal_code,
@ -31,7 +38,8 @@ const formData = ref({
});
async function save() {
const { country_code, organisation_country_code, ...profileData } = formData.value;
const { country_code, organisation_country_code, phone_number, ...profileData } =
formData.value;
const typedProfileData: Partial<User> = { ...profileData };
typedProfileData.country = countries.value.find(
@ -41,6 +49,10 @@ async function save() {
(c) => c.country_code === organisation_country_code
);
if (phone_number) {
typedProfileData.phone_number = normalizeSwissPhoneNumber(phone_number);
}
await user.updateUserProfile(typedProfileData);
emit("save");
}
@ -126,6 +138,28 @@ async function avatarUpload(e: Event) {
disabled
class="disabled:bg-gray-50 mb-4 block w-full border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 disabled:cursor-not-allowed disabled:text-gray-500 disabled:ring-gray-200 sm:max-w-sm sm:text-sm sm:leading-6"
/>
<label for="phone" class="block pb-1.5 leading-6">
{{ $t("a.Telefonnummer") }}
</label>
<div>
<input
id="phone"
v-model="formData.phone_number"
type="text"
name="phone"
autocomplete="phone-number"
class="disabled:bg-gray-50 mb-4 block w-full border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 disabled:cursor-not-allowed disabled:text-gray-500 disabled:ring-gray-200 sm:max-w-sm sm:text-sm sm:leading-6"
/>
</div>
<label for="birth-date" class="block pb-1.5 leading-6">
{{ $t("a.Geburtsdatum") }}
</label>
<div class="mb-4 block w-full py-1.5 sm:max-w-sm sm:text-sm sm:leading-6">
<ItDatePicker v-model="formData.birth_date"></ItDatePicker>
</div>
<label class="block pb-1.5 leading-6">
{{ $t("a.Profilbild") }}
</label>
@ -264,6 +298,22 @@ async function avatarUpload(e: Event) {
{{ $t("a.Firmenanschrift") }}
</h4>
<div class="flex flex-col justify-start md:flex-row md:space-x-4">
<div class="w-full md:max-w-lg">
<label for="org-street-address" class="block pb-1.5 leading-6">
{{ $t("a.Firmenname") }}
</label>
<input
id="org-detail-name"
v-model="formData.organisation_detail_name"
type="text"
name="org-detail-name"
class="disabled:bg-gray-50 mb-4 block w-full border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 disabled:cursor-not-allowed disabled:text-gray-500 disabled:ring-gray-200 sm:text-sm sm:leading-6"
/>
</div>
</div>
<div class="flex flex-col justify-start md:flex-row md:space-x-4">
<div class="w-full md:max-w-sm">
<label for="org-street-address" class="block pb-1.5 leading-6">
@ -389,7 +439,7 @@ async function avatarUpload(e: Event) {
<button class="btn btn-secondary" @click="emit('cancel')">
{{ $t("general.cancel") }}
</button>
<button class="btn btn-primary" @click="save">
<button class="btn btn-primary" data-cy="saveButton" @click="save">
{{ $t("general.save") }}
</button>
</div>

View File

@ -3,6 +3,8 @@ import { useUserStore } from "@/stores/user";
import { computed } from "vue";
import { useEntities } from "@/services/entities";
import { useTranslation } from "i18next-vue";
import dayjs from "dayjs";
import { displaySwissPhoneNumber } from "@/utils/phone";
const { t } = useTranslation();
@ -10,21 +12,20 @@ const user = useUserStore();
const { organisations } = useEntities();
const privateAddress = computed(() => {
let addressText = `${user.street} ${user.street_number}`.trim();
if (user.postal_code || user.city) {
if (addressText.length) {
addressText += ", ";
}
addressText += `${user.postal_code} ${user.city}`;
}
if (user.country) {
if (addressText.length) {
addressText += ", ";
}
addressText += user.country.name;
const textParts = [];
if (user.street || user.street_number) {
textParts.push(`${user.street} ${user.street_number}`.trim());
}
return addressText.trim();
if (user.postal_code || user.city) {
textParts.push(`${user.postal_code} ${user.city}`);
}
if (textParts.length && user.country) {
textParts.push(user.country.name);
}
return textParts;
});
const organisationName = computed(() => {
@ -36,22 +37,25 @@ const organisationName = computed(() => {
});
const orgAddress = computed(() => {
let addressText =
`${user.organisation_street} ${user.organisation_street_number}`.trim();
if (user.organisation_postal_code || user.organisation_city) {
if (addressText.length) {
addressText += ", ";
}
addressText += `${user.organisation_postal_code} ${user.organisation_city}`;
}
if (user.organisation_country) {
if (addressText.length) {
addressText += ", ";
}
addressText += user.organisation_country.name;
const textParts = [];
if (user.organisation_detail_name) {
textParts.push(user.organisation_detail_name);
}
return addressText.trim();
if (user.organisation_street || user.organisation_street_number) {
textParts.push(
`${user.organisation_street} ${user.organisation_street_number}`.trim()
);
}
if (user.organisation_postal_code || user.organisation_city) {
textParts.push(`${user.organisation_postal_code} ${user.organisation_city}`);
}
if (textParts.length && user.organisation_country) {
textParts.push(user.organisation_country.name);
}
return textParts;
});
const invoiceAddress = computed(() => {
@ -67,20 +71,45 @@ const invoiceAddress = computed(() => {
<h3 class="mb-2">{{ $t("a.Persönliche Informationen") }}</h3>
<div class="sm:grid sm:grid-cols-3 sm:items-start sm:gap-8 sm:py-6">
<label class="block font-semibold leading-6">{{ $t("a.Vorname") }}</label>
<div class="mb-3 sm:col-span-2 sm:mb-0">{{ user.first_name }}</div>
<div class="mb-3 sm:col-span-2 sm:mb-0" data-cy="firstName">
{{ user.first_name }}
</div>
<label class="block font-semibold leading-6">{{ $t("a.Name") }}</label>
<div class="mb-3 sm:col-span-2 sm:mb-0">{{ user.last_name }}</div>
<div class="mb-3 sm:col-span-2 sm:mb-0" data-cy="lastName">
{{ user.last_name }}
</div>
<label class="block font-semibold leading-6">
{{ $t("a.E-Mail Adresse") }}
</label>
<div class="mb-3 sm:col-span-2 sm:mb-0">{{ user.email }}</div>
<div class="mb-3 sm:col-span-2 sm:mb-0" data-cy="email">{{ user.email }}</div>
<label class="block font-semibold leading-6">
{{ $t("a.Telefonnummer") }}
</label>
<div class="mb-3 sm:col-span-2 sm:mb-0" data-cy="phone">
<span v-if="user.phone_number">
{{ displaySwissPhoneNumber(user.phone_number) }}
</span>
<span v-else class="text-gray-800">{{ $t("a.Keine Angabe") }}</span>
</div>
<label class="block font-semibold leading-6">
{{ $t("a.Geburtsdatum") }}
</label>
<div class="mb-3 sm:col-span-2 sm:mb-0" data-cy="birthDate">
<span v-if="user.birth_date">
{{ dayjs(user.birth_date).format("DD.MM.YYYY") }}
</span>
<span v-else class="text-gray-800">{{ $t("a.Keine Angabe") }}</span>
</div>
<label class="block font-semibold leading-6">
{{ $t("a.Privatadresse") }}
</label>
<div class="mb-3 sm:col-span-2 sm:mb-0">
<template v-if="privateAddress">
{{ privateAddress }}
</template>
<div class="mb-3 sm:col-span-2 sm:mb-0" data-cy="privateAddress">
<div v-if="privateAddress.length">
<span v-for="(line, index) in privateAddress" :key="index">
{{ line }}
<br />
</span>
</div>
<span v-else class="text-gray-800">{{ $t("a.Keine Angabe") }}</span>
</div>
</div>
@ -89,14 +118,19 @@ const invoiceAddress = computed(() => {
<h3 class="my-2">{{ $t("a.Geschäftsdaten") }}</h3>
<div class="sm:grid sm:grid-cols-3 sm:items-start sm:gap-8 sm:py-6">
<label class="block font-semibold leading-6">{{ $t("a.Unternehmen") }}</label>
<div class="mb-3 sm:col-span-2 sm:mb-0">{{ organisationName }}</div>
<div class="mb-3 sm:col-span-2 sm:mb-0" data-cy="organisationDetailName">
{{ organisationName }}
</div>
<label class="block font-semibold leading-6">
{{ $t("a.Firmenanschrift") }}
</label>
<div class="sm:col-span-2">
<template v-if="orgAddress">
{{ orgAddress }}
</template>
<div v-if="orgAddress" data-cy="organisationAddress">
<span v-for="(line, index) in orgAddress" :key="index">
{{ line }}
<br />
</span>
</div>
<span v-else class="text-gray-800">{{ $t("a.Keine Angabe") }}</span>
</div>
</div>

View File

@ -0,0 +1,60 @@
<script setup lang="ts">
import { useUserStore } from "@/stores/user";
import VueDatePicker from "@vuepic/vue-datepicker";
import "@vuepic/vue-datepicker/dist/main.css";
import { defineProps, withDefaults, defineModel } from "vue";
const model = defineModel<string>();
export interface Props {
noToday?: boolean;
required?: boolean;
placeholder?: string;
startDate?: string;
maxDate?: string;
}
const props = withDefaults(defineProps<Props>(), {
required: false,
placeholder: "15.06.1982",
startDate: "1982-01-01",
maxDate: "2007-01-01",
});
const userStore = useUserStore();
</script>
<template>
<VueDatePicker
v-model="model"
format="dd.MM.yyyy"
model-type="yyyy-MM-dd"
name="date"
prevent-min-max-navigation
:required="props.required"
:enable-time-picker="false"
text-input
:no-today="props.noToday"
:max-date="props.maxDate"
:placeholder="props.placeholder"
:start-date="props.startDate"
:locale="userStore.language"
:cancel-text="$t('a.Abbrechen')"
:select-text="$t('a.Auswählen')"
></VueDatePicker>
</template>
<style>
.dp__theme_light {
--dp-text-color: #585f63;
--dp-primary-color: #41b5fa;
--dp-border-color-focus: #3d6dcc;
}
:root {
--dp-font-family: "Buenos Aires" sans-serif;
--dp-border-radius: none;
--dp-font-size: 0.875rem;
--dp-cell-border-radius: none;
}
</style>

View File

@ -1,18 +1,30 @@
<script setup lang="ts">
import ItRow from "@/components/ui/ItRow.vue";
defineProps<{
export interface Props {
avatarUrl: string;
name: string;
}>();
extraInfo?: string;
}
const props = withDefaults(defineProps<Props>(), {
extraInfo: "",
});
</script>
<template>
<ItRow>
<template #firstRow>
<slot name="leading"></slot>
<img class="mr-2 h-11 w-11 rounded-full" :src="avatarUrl" />
<p class="text-bold lg:leading-[45px]">{{ name }}</p>
<img class="mr-2 h-11 w-11 rounded-full" :src="props.avatarUrl" />
<div :class="props.extraInfo ? 'leading-5' : ''">
<p class="text-bold" :class="props.extraInfo ? '' : 'lg:leading-[45px]'">
{{ props.name }}
</p>
<p v-if="props.extraInfo" class="font-normal" data-cy="extra-info">
{{ props.extraInfo }}
</p>
</div>
</template>
<template #center>
<slot name="center"></slot>

19
client/src/consts.ts Normal file
View File

@ -0,0 +1,19 @@
// Course IDs
export const COURSE_TEST_ID = -1;
export const COURSE_UK = -3;
export const COURSE_VERSICHERUNGSVERMITTLERIN_ID = -4;
export const COURSE_UK_FR = -5;
export const COURSE_UK_TRAINING = -6;
export const COURSE_UK_TRAINING_FR = -7;
export const COURSE_UK_IT = -8;
export const COURSE_UK_TRAINING_IT = -9;
export const COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID = -10;
export const COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID = -11;
export const COURSE_VERSICHERUNGSVERMITTLERIN_PRUEFUNG_ID = -12;
export const COURSE_MOTORFAHRZEUG_PRUEFUNG_ID = -13;
// Organization IDs
export const ORGANISATION_OTHER_BROKER_ID = 1;
export const ORGANISATION_OTHER_HEALTH_INSURANCE_ID = 2;
export const ORGANISATION_OTHER_PRIVATE_INSURANCE_ID = 3;
export const ORGANISATION_NO_COMPANY_ID = 31;

View File

@ -20,7 +20,7 @@ const documents = {
"\n query assignmentCompletionQuery(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n ) {\n assignment(id: $assignmentId) {\n assignment_type\n needs_expert_evaluation\n max_points\n content_type\n effort_required\n evaluation_description\n evaluation_document_url\n evaluation_tasks\n id\n intro_text\n performance_objectives\n slug\n tasks\n title\n translation_key\n solution_sample {\n id\n url\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n learning_content_page_id: $learningContentId\n ) {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_user {\n id\n first_name\n last_name\n }\n assignment_user {\n avatar_url\n first_name\n last_name\n id\n }\n evaluation_points\n evaluation_max_points\n evaluation_points_deducted\n evaluation_points_deducted_reason\n evaluation_points_final\n\n evaluation_passed\n edoniq_extended_time_flag\n completion_data\n task_completion_data\n }\n }\n": types.AssignmentCompletionQueryDocument,
"\n query competenceCertificateQuery($courseSlug: String!, $courseSessionId: ID!) {\n competence_certificate_list(course_slug: $courseSlug) {\n ...CoursePageFields\n competence_certificates {\n ...CoursePageFields\n assignments {\n ...CoursePageFields\n assignment_type\n max_points\n competence_certificate_weight\n completion(course_session_id: $courseSessionId) {\n id\n completion_status\n submitted_at\n evaluation_points\n evaluation_points_deducted\n evaluation_points_final\n evaluation_max_points\n evaluation_passed\n }\n learning_content {\n ...CoursePageFields\n circle {\n id\n title\n slug\n }\n }\n }\n }\n }\n }\n": types.CompetenceCertificateQueryDocument,
"\n query competenceCertificateForUserQuery(\n $courseSlug: String!\n $courseSessionId: ID!\n $userId: UUID!\n ) {\n competence_certificate_list_for_user(course_slug: $courseSlug, user_id: $userId) {\n ...CoursePageFields\n competence_certificates {\n ...CoursePageFields\n assignments {\n ...CoursePageFields\n assignment_type\n max_points\n competence_certificate_weight\n completion(course_session_id: $courseSessionId) {\n id\n completion_status\n submitted_at\n evaluation_points\n evaluation_points_final\n evaluation_points_deducted\n evaluation_max_points\n evaluation_passed\n }\n learning_content {\n ...CoursePageFields\n circle {\n id\n title\n slug\n }\n }\n }\n }\n }\n }\n": types.CompetenceCertificateForUserQueryDocument,
"\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n configuration {\n id\n enable_circle_documents\n enable_learning_mentor\n enable_competence_certificates\n }\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n": types.CourseSessionDetailDocument,
"\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n configuration {\n id\n enable_circle_documents\n enable_learning_mentor\n enable_competence_certificates\n }\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n optional_attendance\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n": types.CourseSessionDetailDocument,
"\n query courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n configuration {\n id\n enable_circle_documents\n enable_learning_mentor\n enable_competence_certificates\n is_uk\n }\n action_competences {\n competence_id\n ...CoursePageFields\n performance_criteria {\n competence_id\n learning_unit {\n id\n slug\n evaluate_url\n }\n ...CoursePageFields\n }\n }\n learning_path {\n ...CoursePageFields\n topics {\n is_visible\n ...CoursePageFields\n circles {\n description\n goals\n ...CoursePageFields\n learning_sequences {\n icon\n ...CoursePageFields\n learning_units {\n evaluate_url\n ...CoursePageFields\n performance_criteria {\n ...CoursePageFields\n }\n learning_contents {\n can_user_self_toggle_course_completion\n content_url\n minutes\n description\n ...CoursePageFields\n ... on LearningContentAssignmentObjectType {\n assignment_type\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentEdoniqTestObjectType {\n checkbox_text\n has_extended_time_test\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentRichTextObjectType {\n text\n }\n }\n }\n }\n }\n }\n }\n }\n }\n": types.CourseQueryDocument,
"\n query dashboardConfig {\n dashboard_config {\n id\n slug\n name\n dashboard_type\n course_configuration {\n id\n enable_circle_documents\n enable_learning_mentor\n enable_competence_certificates\n is_uk\n }\n }\n }\n": types.DashboardConfigDocument,
"\n query dashboardProgress($courseId: ID!) {\n course_progress(course_id: $courseId) {\n _id\n course_id\n session_to_continue_id\n competence {\n _id\n total_count\n success_count\n fail_count\n }\n assignment {\n _id\n total_count\n points_max_count\n points_achieved_count\n }\n }\n }\n": types.DashboardProgressDocument,
@ -75,7 +75,7 @@ export function graphql(source: "\n query competenceCertificateForUserQuery(\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 courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n configuration {\n id\n enable_circle_documents\n enable_learning_mentor\n enable_competence_certificates\n }\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n configuration {\n id\n enable_circle_documents\n enable_learning_mentor\n enable_competence_certificates\n }\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n"];
export function graphql(source: "\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n configuration {\n id\n enable_circle_documents\n enable_learning_mentor\n enable_competence_certificates\n }\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n optional_attendance\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n configuration {\n id\n enable_circle_documents\n enable_learning_mentor\n enable_competence_certificates\n }\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n optional_attendance\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

File diff suppressed because one or more lines are too long

View File

@ -713,6 +713,7 @@ type CourseSessionUserObjectsType {
avatar_url: String!
role: String!
circles: [CourseSessionUserExpertCircleType!]!
optional_attendance: Boolean
}
type CourseSessionUserExpertCircleType {

View File

@ -192,6 +192,7 @@ export const COURSE_SESSION_DETAIL_QUERY = graphql(`
title
slug
}
optional_attendance
}
attendance_courses {
id

View File

@ -13,6 +13,7 @@ import VerticalBarChart from "@/components/ui/VerticalBarChart.vue";
import LearningPathCircle from "@/pages/learningPath/learningPathPage/LearningPathCircle.vue";
import logger from "loglevel";
import { reactive, ref } from "vue";
import "@vuepic/vue-datepicker/dist/main.css";
const state = reactive({
checkboxValue: true,

View File

@ -224,6 +224,9 @@ watch(
:name="`${csu.first_name} ${csu.last_name}`"
:avatar-url="csu.avatar_url"
:class="0 === index ? 'border-none' : ''"
:extra-info="
csu.optional_attendance ? `${$t('a.Optionale Anwesenheit')}` : ''
"
>
<template #leading>
<ItCheckbox

View File

@ -6,9 +6,14 @@ import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { computed, ref, watch } from "vue";
import { useTranslation } from "i18next-vue";
import _ from "lodash";
import type { DashboardPersonCourseSessionType } from "@/services/dashboard";
import {
type DashboardPersonCourseSessionType,
exportPersons,
} from "@/services/dashboard";
import { useRouteQuery } from "@vueuse/router";
import type { DashboardPersonsPageMode } from "@/types";
import type { DashboardPersonsPageMode, StatisticsFilterItem } from "@/types";
import { useUserStore } from "@/stores/user";
import { exportDataAsXls } from "@/utils/export";
log.debug("DashboardPersonsPage created");
@ -28,6 +33,7 @@ type MenuItem = {
};
const { t } = useTranslation();
const userStore = useUserStore();
const { loading, dashboardPersons } = useDashboardPersonsDueDates(props.mode);
@ -227,6 +233,32 @@ function personRoleDisplayValue(personCourseSession: DashboardPersonCourseSessio
return "";
}
function exportData() {
const courseSessionIdsSet = new Set<string>();
// get all course session ids from users
if (selectedSession.value.id === UNFILTERED) {
for (const person of filteredPersons.value) {
for (const courseSession of person.course_sessions) {
courseSessionIdsSet.add(courseSession.id);
}
}
} else {
courseSessionIdsSet.add(selectedSession.value.id);
}
// construct StatisticsFilterItems for export call
const items: StatisticsFilterItem[] = [];
for (const csId of courseSessionIdsSet) {
items.push({
_id: "",
course_session_id: csId,
generation: "",
circle_id: "",
});
}
exportDataAsXls(items, exportPersons, userStore.language);
}
watch(selectedCourse, () => {
selectedRegion.value = regions.value[0];
});
@ -253,7 +285,18 @@ watch(selectedRegion, () => {
<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-4">{{ $t("a.Personen") }}</h2>
<div class="mb-10 flex items-center justify-between">
<h2 class="my-4">{{ $t("a.Personen") }}</h2>
<button
v-if="userStore.course_session_experts.length > 0"
class="flex"
data-cy="export-button"
@click="exportData"
>
<it-icon-export></it-icon-export>
<span class="ml inline-block">{{ $t("a.Als Excel exportieren") }}</span>
</button>
</div>
<div class="bg-white px-4 py-2">
<section
v-if="filtersVisible"

View File

@ -51,10 +51,6 @@ const submissionDeadline = computed(() => {
?.submission_deadline;
});
// FIXME daniel: `useRouteQuery` from usevue is currently the reason that we have to
// fix the version of @vueuse/router and @vueuse/core to 10.1.0
// it fails with version 10.2.0. I have a reminder to check out the situation
// at the end of July 2023
// 0 = introduction, 1 - n = tasks, n+1 = submission
const stepIndex = useRouteQuery("step", "0", { transform: Number, mode: "push" });

View File

@ -3,9 +3,9 @@ import WizardPage from "@/components/onboarding/WizardPage.vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { computed, ref, watch } from "vue";
import { useUserStore } from "@/stores/user";
import { useRoute } from "vue-router";
import { useRoute, useRouter } from "vue-router";
import { useTranslation } from "i18next-vue";
import { profileNextRoute } from "@/services/onboarding";
import { isOtherOrganisation, profileNextRoute } from "@/services/onboarding";
import { useEntities } from "@/services/entities";
import AvatarImage from "@/components/ui/AvatarImage.vue";
@ -13,9 +13,12 @@ const { t } = useTranslation();
const user = useUserStore();
const route = useRoute();
const router = useRouter();
const { organisations } = useEntities();
const organisationDetailName = ref<string>("");
const selectedOrganisation = ref({
id: 0,
name: t("a.Auswählen"),
@ -35,7 +38,11 @@ watch(
);
const validOrganisation = computed(() => {
return selectedOrganisation.value.id !== 0;
const organisationSelected = selectedOrganisation.value.id !== 0;
const organisationNameSet =
!isOtherOrganisation(selectedOrganisation.value.id) ||
!!organisationDetailName.value.trim();
return organisationSelected && organisationNameSet;
});
const avatarError = ref(false);
@ -56,15 +63,21 @@ async function avatarUpload(e: Event) {
}
}
watch(selectedOrganisation, async (organisation) => {
async function updateUserProfile() {
await user.updateUserProfile({
organisation: organisation.id,
organisation: selectedOrganisation.value.id,
organisation_detail_name: organisationDetailName.value.trim(),
});
});
}
const nextRoute = computed(() => {
return profileNextRoute(route.params.courseType);
});
async function navigateNextRoute() {
await updateUserProfile();
await router.push({ name: nextRoute.value });
}
</script>
<template>
@ -86,6 +99,19 @@ const nextRoute = computed(() => {
<ItDropdownSelect v-model="selectedOrganisation" :items="organisations" />
<div v-if="isOtherOrganisation(selectedOrganisation.id)" class="my-8">
<label for="organisationDetailName" class="heading-3 block pb-1.5">
{{ $t("a.Firmenname") }}
</label>
<input
id="organisationDetailName"
v-model="organisationDetailName"
type="text"
name="phone"
class="block w-full border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 disabled:cursor-not-allowed disabled:text-gray-500 disabled:ring-gray-200 sm:max-w-sm sm:text-sm sm:leading-6"
/>
</div>
<div class="mt-16 flex flex-col justify-between gap-12 lg:flex-row lg:gap-24">
<div>
<h3 class="mb-3">{{ $t("a.Profilbild") }}</h3>
@ -117,18 +143,16 @@ const nextRoute = computed(() => {
</template>
<template #footer>
<router-link v-slot="{ navigate }" :to="{ name: nextRoute }" custom>
<button
:disabled="!validOrganisation"
class="btn-blue flex items-center"
role="link"
data-cy="continue-button"
@click="navigate"
>
{{ $t("general.next") }}
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
</button>
</router-link>
<button
:disabled="!validOrganisation"
class="btn-blue flex items-center"
role="link"
data-cy="continue-button"
@click="navigateNextRoute"
>
{{ $t("general.next") }}
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
</button>
</template>
</WizardPage>
</template>

View File

@ -13,6 +13,12 @@ import DatatransCembraDeviceFingerprint from "@/components/onboarding/DatatransC
import { getLocalSessionKey } from "@/statistics";
import log from "loglevel";
import { normalizeSwissPhoneNumber, validatePhoneNumber } from "@/utils/phone";
import {
ORGANISATION_NO_COMPANY_ID,
ORGANISATION_OTHER_BROKER_ID,
ORGANISATION_OTHER_HEALTH_INSURANCE_ID,
ORGANISATION_OTHER_PRIVATE_INSURANCE_ID,
} from "@/consts";
const props = defineProps({
courseType: {
@ -31,11 +37,14 @@ const userOrganisationName = computed(() => {
}
// Those IDs do not represent a company
// 1: Other broker
// 2: Other insurance
// 3: Other private insurance
// 31: No company relation
if ([1, 2, 3, 31].includes(user.organisation)) {
if (
[
ORGANISATION_OTHER_BROKER_ID,
ORGANISATION_OTHER_HEALTH_INSURANCE_ID,
ORGANISATION_OTHER_PRIVATE_INSURANCE_ID,
ORGANISATION_NO_COMPANY_ID,
].includes(user.organisation)
) {
return null;
}
@ -256,11 +265,10 @@ const executePayment = async () => {
<p v-if="paymentError" class="text-bold mt-12 text-lg text-red-700">
{{
$t("a.Fehler bei der Zahlung. Bitte versuche es erneut oder kontaktiere uns")
}}:
<a href="mailto:vermittler@vbv-afa.ch" class="underline">
vermittler@vbv-afa.ch
</a>
$t(
"a.Fehler bei der Zahlung. Bitte versuche es erneut oder wähle eine andere Zahlungsmethode."
)
}}
</p>
<h3 class="mb-4 mt-10">{{ $t("a.Adresse") }}</h3>
@ -285,7 +293,7 @@ const executePayment = async () => {
{{ formErrors.personal.join(", ") }}
</p>
<section v-if="address.payment_method !== 'cembra_byjuno'">
<section>
<div class="mt-4">
<button
v-if="!withCompanyAddress"

View File

@ -45,7 +45,11 @@ function startEditMode() {
<div class="flex flex-grow flex-col space-y-4 px-8 py-8 md:px-16">
<div v-if="!editMode" class="flex justify-end space-x-4">
<button class="btn btn-secondary" @click="startEditMode">
<button
class="btn btn-secondary"
data-cy="editProfileButton"
@click="startEditMode"
>
{{ $t("a.Profil bearbeiten") }}
</button>
</div>

View File

@ -53,7 +53,17 @@ onMounted(() => {
<div class="flex flex-col">
<h2 class="mb-2">{{ user.first_name }} {{ user.last_name }}</h2>
<p class="mb-2">{{ user.email }}</p>
<p class="text-gray-800">{{ $t("a.Teilnehmer") }}</p>
<p class="text-gray-800">
{{ $t("a.Teilnehmer") }}
<span
v-if="
user.optional_attendance.some((id: string) => id === courseSession.id)
"
data-cy="optional-attendance"
>
{{ $t("a.Optionale Anwesenheit") }}
</span>
</p>
</div>
</div>
</div>

View File

@ -221,6 +221,15 @@ export async function exportCompetenceElements(
});
}
export async function exportPersons(
data: XlsExportRequestData,
language: string
): Promise<XlsExportResponseData> {
return await itPost("/api/dashboard/export/persons/", data, {
headers: { "Accept-Language": language },
});
}
export function courseIdForCourseSlug(
dashboardConfigs: DashboardCourseConfigType[],
courseSlug: string

View File

@ -1,4 +1,9 @@
import { isString, startsWith } from "lodash";
import {
ORGANISATION_OTHER_BROKER_ID,
ORGANISATION_OTHER_HEALTH_INSURANCE_ID,
ORGANISATION_OTHER_PRIVATE_INSURANCE_ID,
} from "@/consts";
export function profileNextRoute(courseType: string | string[]) {
if (courseType === "uk") {
@ -10,3 +15,11 @@ export function profileNextRoute(courseType: string | string[]) {
}
return "";
}
export function isOtherOrganisation(orgId: number) {
return [
ORGANISATION_OTHER_BROKER_ID,
ORGANISATION_OTHER_HEALTH_INSURANCE_ID,
ORGANISATION_OTHER_PRIVATE_INSURANCE_ID,
].includes(orgId);
}

View File

@ -17,22 +17,31 @@ describe("checkout.cy.js", () => {
cy.get('[data-cy="account-confirm-title"]').should(
"contain",
"Konto erstellen"
"Konto erstellen",
);
cy.get('[data-cy="continue-button"]').click();
cy.get('[data-cy="account-profile-title"]').should(
"contain",
"Profil ergänzen"
"Profil ergänzen",
);
cy.get('[data-cy="dropdown-select"]').click();
cy.get('[data-cy="dropdown-select-option-Baloise"]').click();
cy.get(
'[data-cy="dropdown-select-option-andere Krankenversicherer"]',
).click();
cy.get("#organisationDetailName").type("FdH GmbH");
cy.get('[data-cy="continue-button"]').click();
cy.loadUser("id", TEST_USER_EMPTY_ID).then((u) => {
expect(u.organisation_detail_name).to.equal("FdH GmbH");
// 2 -> andere Krankenversicherer
expect(u.organisation).to.equal(2);
});
// Adressdaten ausfüllen
cy.get('[data-cy="account-checkout-title"]').should(
"contain",
"Lehrgang kaufen"
"Lehrgang kaufen",
);
cy.get("#street-address").type("Eggersmatt");
cy.get("#street-number").type("32");
@ -40,7 +49,7 @@ describe("checkout.cy.js", () => {
cy.get("#city").type("Zumholz");
cy.get('[data-cy="add-company-address"]').click();
cy.get("#company-name").type("Iterativ GmbH");
// cy.get("#company-name").clear().type("Iterativ GmbH");
cy.get("#company-street-address").type("Brückfeldstrasse");
cy.get("#company-street-number").type("16");
cy.get("#company-postal-code").type("3012");
@ -60,7 +69,7 @@ describe("checkout.cy.js", () => {
expect(ci.country).to.equal("CH");
expect(ci.invoice_address).to.equal("org");
expect(ci.organisation_detail_name).to.equal("Iterativ GmbH");
expect(ci.organisation_detail_name).to.equal("FdH GmbH");
expect(ci.organisation_street).to.equal("Brückfeldstrasse");
expect(ci.organisation_street_number).to.equal("16");
expect(ci.organisation_postal_code).to.equal("3012");
@ -72,12 +81,32 @@ describe("checkout.cy.js", () => {
expect(ci.state).to.equal("ongoing");
});
cy.loadUser("id", TEST_USER_EMPTY_ID).then((u) => {
expect(u.first_name).to.equal("Flasche");
expect(u.last_name).to.equal("Leer");
expect(u.street).to.equal("Eggersmatt");
expect(u.street_number).to.equal("32");
expect(u.postal_code).to.equal("1719");
expect(u.city).to.equal("Zumholz");
expect(u.country).to.equal("CH");
expect(u.invoice_address).to.equal("org");
expect(u.organisation_detail_name).to.equal("FdH GmbH");
expect(u.organisation_street).to.equal("Brückfeldstrasse");
expect(u.organisation_street_number).to.equal("16");
expect(u.organisation_postal_code).to.equal("3012");
expect(u.organisation_city).to.equal("Bern");
// 2 -> andere Krankenversicherer
expect(u.organisation).to.equal(2);
});
// pay
cy.get('[data-cy="pay-button"]').click();
cy.get('[data-cy="checkout-success-title"]').should(
"contain",
"Gratuliere"
"Gratuliere",
);
// wait for payment callback
cy.wait(3000);
@ -86,7 +115,7 @@ describe("checkout.cy.js", () => {
// back on dashboard page
cy.get('[data-cy="db-course-title"]').should(
"contain",
"Versicherungsvermittler"
"Versicherungsvermittler",
);
cy.loadCheckoutInformation("user_id", TEST_USER_EMPTY_ID).then((ci) => {
@ -102,13 +131,13 @@ describe("checkout.cy.js", () => {
cy.get('[data-cy="account-confirm-title"]').should(
"contain",
"Konto erstellen"
"Konto erstellen",
);
cy.get('[data-cy="continue-button"]').click();
cy.get('[data-cy="account-profile-title"]').should(
"contain",
"Profil ergänzen"
"Profil ergänzen",
);
cy.get('[data-cy="dropdown-select"]').click();
cy.get('[data-cy="dropdown-select-option-Baloise"]').click();
@ -117,10 +146,10 @@ describe("checkout.cy.js", () => {
// Adressdaten ausfüllen
cy.get('[data-cy="account-checkout-title"]').should(
"contain",
"Lehrgang kaufen"
"Lehrgang kaufen",
);
cy.get('#paymentMethod').select('cembra_byjuno');
cy.get("#paymentMethod").select("cembra_byjuno");
cy.get("#street-address").type("Eggersmatt");
cy.get("#street-number").type("32");
@ -132,32 +161,38 @@ describe("checkout.cy.js", () => {
cy.get('[data-cy="continue-pay"]').click();
cy.loadExternalApiRequestLog("request_username", "empty@example.com").then((entry) => {
// ends with "/v1/transactions""
expect(entry.api_url).to.contain("/v1/transactions");
expect(entry.request_username).to.contain("empty@example.com");
cy.loadExternalApiRequestLog("request_username", "empty@example.com").then(
(entry) => {
// ends with "/v1/transactions""
expect(entry.api_url).to.contain("/v1/transactions");
expect(entry.request_username).to.contain("empty@example.com");
expect(entry.api_request_data.amount).to.equal(32400);
expect(entry.api_request_data.currency).to.equal("CHF");
expect(entry.api_request_data.autoSettle).to.equal(true);
expect(entry.api_request_data.amount).to.equal(32400);
expect(entry.api_request_data.currency).to.equal("CHF");
expect(entry.api_request_data.autoSettle).to.equal(true);
expect(entry.api_request_data.customer.firstName).to.equal("Flasche");
expect(entry.api_request_data.customer.lastName).to.equal("Leer");
expect(entry.api_request_data.customer.street).to.equal("Eggersmatt 32");
expect(entry.api_request_data.customer.zipCode).to.equal("1719");
expect(entry.api_request_data.customer.city).to.equal("Zumholz");
expect(entry.api_request_data.customer.country).to.equal("CH");
expect(entry.api_request_data.customer.type).to.equal("P");
expect(entry.api_request_data.customer.phone).to.equal("+41792018586");
expect(entry.api_request_data.customer.birthDate).to.equal("1982-06-09");
expect(entry.api_request_data.customer.firstName).to.equal("Flasche");
expect(entry.api_request_data.customer.lastName).to.equal("Leer");
expect(entry.api_request_data.customer.street).to.equal(
"Eggersmatt 32",
);
expect(entry.api_request_data.customer.zipCode).to.equal("1719");
expect(entry.api_request_data.customer.city).to.equal("Zumholz");
expect(entry.api_request_data.customer.country).to.equal("CH");
expect(entry.api_request_data.customer.type).to.equal("P");
expect(entry.api_request_data.customer.phone).to.equal("+41792018586");
expect(entry.api_request_data.customer.birthDate).to.equal(
"1982-06-09",
);
expect(entry.api_request_data.INT.repaymentType).to.equal(3);
expect(entry.api_request_data.INT.riskOwner).to.equal("IJ");
expect(entry.api_request_data.INT.subtype).to.equal("INVOICE");
expect(entry.api_request_data.INT.deviceFingerprintId).to.not.be.empty;
expect(entry.api_request_data.INT.repaymentType).to.equal(3);
expect(entry.api_request_data.INT.riskOwner).to.equal("IJ");
expect(entry.api_request_data.INT.subtype).to.equal("INVOICE");
expect(entry.api_request_data.INT.deviceFingerprintId).to.not.be.empty;
expect(true).to.be.true;
});
expect(true).to.be.true;
},
);
// check that results are stored on server
cy.loadCheckoutInformation("user_id", TEST_USER_EMPTY_ID).then((ci) => {
@ -183,5 +218,23 @@ describe("checkout.cy.js", () => {
expect(ci.ip_address).to.not.be.empty;
expect(ci.device_fingerprint_session_key).to.not.be.empty;
});
cy.loadUser("id", TEST_USER_EMPTY_ID).then((u) => {
expect(u.first_name).to.equal("Flasche");
expect(u.last_name).to.equal("Leer");
expect(u.street).to.equal("Eggersmatt");
expect(u.street_number).to.equal("32");
expect(u.postal_code).to.equal("1719");
expect(u.city).to.equal("Zumholz");
expect(u.country).to.equal("CH");
expect(u.phone_number).to.equal("+41792018586");
expect(u.birth_date).to.equal("1982-06-09");
expect(u.invoice_address).to.equal("prv");
// 7 -> Baloise
expect(u.organisation).to.equal(7);
});
});
});

View File

@ -0,0 +1,11 @@
import { login } from "../helpers";
describe("cockpitAttendaceCheck.cy.js", () => {
it("will display optional participants", () => {
cy.manageCommand("cypress_reset --set-optional-attendance-flag");
login("test-trainer1@example.com", "test");
cy.visit("/course/test-lehrgang/cockpit/attendance");
cy.get('[data-cy="extra-info"]').should("contain", "Optionale Anwesenheit");
});
});

View File

@ -26,7 +26,7 @@ function getCurrentDate() {
function verifyExportFileExists(fileName) {
const downloadsFolder = Cypress.config("downloadsFolder");
cy.readFile(
path.join(downloadsFolder, `${fileName}_${getCurrentDate()}.xlsx`)
path.join(downloadsFolder, `${fileName}_${getCurrentDate()}.xlsx`),
).should("exist");
}
@ -39,7 +39,7 @@ function testExport(url, fileName) {
describe("dashboardExport.cy.js", () => {
beforeEach(() => {
cy.manageCommand(
"cypress_reset --create-assignment-evaluation --create-feedback-responses --create-course-completion-performance-criteria --create-attendance-days"
"cypress_reset --create-assignment-evaluation --create-feedback-responses --create-course-completion-performance-criteria --create-attendance-days",
);
});
@ -55,13 +55,17 @@ describe("dashboardExport.cy.js", () => {
it("should download the competence elements export", () => {
testExport(
"/statistic/test-lehrgang/assignment",
"export_kompetenznachweis_elemente"
"export_kompetenznachweis_elemente",
);
});
it("should download the feedback export", () => {
testExport("/statistic/test-lehrgang/feedback", "export_feedback");
});
it("should download the person export", () => {
testExport("/dashboard/persons", "export_personen");
});
});
describe("as trainer", () => {
@ -76,12 +80,16 @@ describe("dashboardExport.cy.js", () => {
it("should download the competence elements export", () => {
testExport(
"/statistic/test-lehrgang/assignment",
"export_kompetenznachweis_elemente"
"export_kompetenznachweis_elemente",
);
});
it("should download the feedback export", () => {
testExport("/statistic/test-lehrgang/feedback", "export_feedback");
});
it("should download the person export", () => {
testExport("/dashboard/persons", "export_personen");
});
});
});

View File

@ -0,0 +1,85 @@
import { TEST_USER_EMPTY_ID } from "../../consts";
import { login } from "../helpers";
describe("personalProfile.cy.js", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
login("empty@example.com", "test");
cy.visit("/profile");
});
it("can edit all profile fields", () => {
cy.get('[data-cy="editProfileButton"]').click();
cy.get("#phone").type("079 201 85 86");
cy.get('[data-test="dp-input"]').type("09.06.1982{enter}");
cy.get("#street-address").type("Hafen");
cy.get("#street-number").type("123");
cy.get("#postal-code").type("DE-20095");
cy.get("#city").type("Hamburg");
cy.get("#country").select("DE");
// andere broker
cy.get("#organisation").select("1");
cy.get("#org-detail-name").type("Judihui GmbH");
cy.get("#org-street-address").type("Auf der Alm");
cy.get("#org-street-number").type("17");
cy.get("#org-postal-code").type("AT-6020");
cy.get("#org-city").type("Innsbruck");
cy.get("#org-country").select("AT");
cy.get("#invoice-address-organisation").click();
cy.get('[data-cy="saveButton"]').click();
// check displayed data
cy.get('[data-cy="firstName"]').should("contain", "Flasche");
cy.get('[data-cy="lastName"]').should("contain", "Leer");
cy.get('[data-cy="email"]').should("contain", "empty@example.com");
cy.get('[data-cy="phone"]').should("contain", "079 201 85 86");
cy.get('[data-cy="birthDate"]').should("contain", "09.06.1982");
cy.get('[data-cy="privateAddress"]').should("contain", "Hafen 123");
cy.get('[data-cy="privateAddress"]').should("contain", "DE-20095 Hamburg");
cy.get('[data-cy="privateAddress"]').should("contain", "Deutschland");
cy.get('[data-cy="organisationDetailName"]').should(
"contain",
"andere Broker",
);
cy.get('[data-cy="organisationAddress"]').should("contain", "Judihui GmbH");
cy.get('[data-cy="organisationAddress"]').should(
"contain",
"Auf der Alm 17",
);
cy.get('[data-cy="organisationAddress"]').should(
"contain",
"AT-6020 Innsbruck",
);
cy.get('[data-cy="organisationAddress"]').should("contain", "Österreich");
// check stored data
cy.loadUser("id", TEST_USER_EMPTY_ID).then((u) => {
expect(u.first_name).to.equal("Flasche");
expect(u.last_name).to.equal("Leer");
expect(u.street).to.equal("Hafen");
expect(u.street_number).to.equal("123");
expect(u.postal_code).to.equal("DE-20095");
expect(u.city).to.equal("Hamburg");
expect(u.country).to.equal("DE");
expect(u.invoice_address).to.equal("org");
expect(u.organisation_detail_name).to.equal("Judihui GmbH");
expect(u.organisation_street).to.equal("Auf der Alm");
expect(u.organisation_street_number).to.equal("17");
expect(u.organisation_postal_code).to.equal("AT-6020");
expect(u.organisation_city).to.equal("Innsbruck");
// 1 -> andere Broker
expect(u.organisation).to.equal(1);
});
});
});

View File

@ -0,0 +1,17 @@
import { login } from "../helpers";
import { TEST_STUDENT1_USER_ID } from "../../consts";
describe("publicProfileAttendance.cy.js", () => {
it("will display optional attendance", () => {
cy.manageCommand("cypress_reset --set-optional-attendance-flag");
login("test-trainer1@example.com", "test");
cy.visit(
`course/test-lehrgang/profile/${TEST_STUDENT1_USER_ID}/learning-path`,
);
cy.get('[data-cy="optional-attendance"]').should(
"contain",
"Optionale Anwesenheit",
);
});
});

View File

@ -178,6 +178,17 @@ Cypress.Commands.add("loadCheckoutInformation", (key, value) => {
)
})
Cypress.Commands.add("loadUser", (key, value) => {
return loadObjectJson(
key,
value,
"vbv_lernwelt.core.models.User",
"vbv_lernwelt.core.serializers.CypressUserSerializer",
true
);
});
Cypress.Commands.add("makeSelfEvaluation", (answers) => {
for (let i = 0; i < answers.length; i++) {
const answer = answers[i]

Binary file not shown.

Binary file not shown.

View File

@ -201,6 +201,7 @@ MIDDLEWARE = [
"vbv_lernwelt.core.middleware.security.SecurityRequestResponseLoggingMiddleware",
"wagtail.contrib.redirects.middleware.RedirectMiddleware",
"vbv_lernwelt.core.middleware.auth.UserLoggedInCookieMiddleWare",
# "vbv_lernwelt.debugtools.middleware.QueryCountDebugMiddleware",
]
# STATIC
@ -332,7 +333,6 @@ X_FRAME_OPTIONS = "DENY"
# EMAIL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
# FIXME how to send emails?
EMAIL_BACKEND = env(
"DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend"
)
@ -682,10 +682,12 @@ if APP_ENVIRONMENT.startswith("prod"):
DATATRANS_PAY_URL = "https://pay.datatrans.com"
else:
DATATRANS_API_ENDPOINT = env(
"DATATRANS_API_ENDPOINT", default="https://api.sandbox.datatrans.com"
"DATATRANS_API_ENDPOINT",
default="http://localhost:8000/server/fakeapi/datatrans/api",
)
DATATRANS_PAY_URL = env(
"DATATRANS_PAY_URL", default="https://pay.sandbox.datatrans.com"
"DATATRANS_PAY_URL",
default="http://localhost:8000/server/fakeapi/datatrans/pay",
)
# default settings for python sftpserver test-server

View File

@ -44,6 +44,7 @@ from vbv_lernwelt.dashboard.views import (
export_attendance_as_xsl,
export_competence_elements_as_xsl,
export_feedback_as_xsl,
export_persons_as_xsl,
get_dashboard_config,
get_dashboard_due_dates,
get_dashboard_persons,
@ -143,6 +144,7 @@ urlpatterns = [
path(r"api/dashboard/export/competence_elements/", export_competence_elements_as_xsl,
name="export_certificate_as_xsl"),
path(r"api/dashboard/export/feedback/", export_feedback_as_xsl, name="export_feedback_as_xsl"),
path(r"api/dashboard/export/persons/", export_persons_as_xsl, name="export_persons_as_xsl"),
# course
path(r"api/course/sessions/", get_course_sessions, name="get_course_sessions"),

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-06-18 15:24+0200\n"
"POT-Creation-Date: 2024-07-27 20:59+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -38,32 +38,32 @@ msgstr ""
msgid "Nicht bestanden"
msgstr ""
#: vbv_lernwelt/assignment/export.py:203 vbv_lernwelt/assignment/export.py:206
#: vbv_lernwelt/assignment/export.py:207
#: vbv_lernwelt/assignment/export.py:204 vbv_lernwelt/assignment/export.py:208
#: vbv_lernwelt/assignment/export.py:209
msgid "Keine Daten"
msgstr ""
#: vbv_lernwelt/core/admin.py:32
#: vbv_lernwelt/core/admin.py:38 vbv_lernwelt/sso/admin.py:83
msgid "Personal info"
msgstr ""
#: vbv_lernwelt/core/admin.py:34
#: vbv_lernwelt/core/admin.py:40
msgid "Permissions"
msgstr ""
#: vbv_lernwelt/core/admin.py:45
#: vbv_lernwelt/core/admin.py:51
msgid "Important dates"
msgstr ""
#: vbv_lernwelt/core/admin.py:47
#: vbv_lernwelt/core/admin.py:53
msgid "Profile"
msgstr ""
#: vbv_lernwelt/core/admin.py:62
#: vbv_lernwelt/core/admin.py:70
msgid "Organisation"
msgstr ""
#: vbv_lernwelt/core/admin.py:75
#: vbv_lernwelt/core/admin.py:83 vbv_lernwelt/sso/admin.py:86
msgid "Additional data"
msgstr ""
@ -87,31 +87,31 @@ msgstr ""
msgid "Lehrgang-Seite"
msgstr ""
#: vbv_lernwelt/course/models.py:272
#: vbv_lernwelt/dashboard/person_export.py:116
msgid "Teilnehmer"
msgstr ""
#: vbv_lernwelt/course/models.py:273
#: vbv_lernwelt/course/models.py:279
msgid "Experte/Trainer"
msgstr ""
#: vbv_lernwelt/course/models.py:332
#: vbv_lernwelt/course/models.py:339
msgid "Dokumente im Circle ein/aus"
msgstr ""
#: vbv_lernwelt/course/models.py:336
#: vbv_lernwelt/course/models.py:343
msgid "Lernmentor-Funktion ein/aus"
msgstr ""
#: vbv_lernwelt/course/models.py:340
#: vbv_lernwelt/course/models.py:347
msgid "Kompetenzweise ein/aus"
msgstr ""
#: vbv_lernwelt/course/models.py:343
#: vbv_lernwelt/course/models.py:350
msgid "Versicherungsvermittler-Lehrgang"
msgstr ""
#: vbv_lernwelt/course/models.py:344
#: vbv_lernwelt/course/models.py:350
msgid "ÜK-Lehrgang"
msgstr ""
@ -119,34 +119,66 @@ msgstr ""
msgid "export_anwesenheit"
msgstr ""
#: vbv_lernwelt/course_session/services/export_attendance.py:92
#: vbv_lernwelt/course_session/services/export_attendance.py:86
msgid "Optionale Anwesenheit"
msgstr ""
#: vbv_lernwelt/course_session/services/export_attendance.py:96
msgid "Anwesenheit"
msgstr ""
#: vbv_lernwelt/course_session/services/export_attendance.py:116
#: vbv_lernwelt/course_session/services/export_attendance.py:119
msgid "Ja"
msgstr ""
#: vbv_lernwelt/course_session/services/export_attendance.py:119
msgid "Nein"
msgstr ""
#: vbv_lernwelt/course_session/services/export_attendance.py:128
msgid "Anwesend"
msgstr ""
#: vbv_lernwelt/course_session/services/export_attendance.py:116
#: vbv_lernwelt/course_session/services/export_attendance.py:128
msgid "Nicht anwesend"
msgstr ""
#: vbv_lernwelt/course_session/services/export_attendance.py:123
#: vbv_lernwelt/course_session/services/export_attendance.py:135
msgid "Vorname"
msgstr ""
#: vbv_lernwelt/course_session/services/export_attendance.py:124
#: vbv_lernwelt/course_session/services/export_attendance.py:136
msgid "Nachname"
msgstr ""
#: vbv_lernwelt/course_session/services/export_attendance.py:125
#: vbv_lernwelt/course_session/services/export_attendance.py:137
msgid "Email"
msgstr "Email"
#: vbv_lernwelt/course_session/services/export_attendance.py:126
#: vbv_lernwelt/course_session/services/export_attendance.py:127
msgid "Lehrvertragsnummer"
msgstr ""
#: vbv_lernwelt/dashboard/person_export.py:16
msgid "export_personen"
msgstr ""
#: vbv_lernwelt/dashboard/person_export.py:68
msgid "Telefon"
msgstr ""
#: vbv_lernwelt/dashboard/person_export.py:69
msgid "Rolle"
msgstr ""
#: vbv_lernwelt/dashboard/person_export.py:118
msgid "Trainer"
msgstr ""
#: vbv_lernwelt/dashboard/person_export.py:120
msgid "Regionenleiter"
msgstr ""
#: vbv_lernwelt/feedback/export.py:19
msgid "export_feedback"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-07-12 09:37+0200\n"
"POT-Creation-Date: 2024-07-30 11:16+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -88,30 +88,31 @@ msgid "Lehrgang-Seite"
msgstr ""
#: vbv_lernwelt/course/models.py:278
#: vbv_lernwelt/dashboard/person_export.py:116
msgid "Teilnehmer"
msgstr ""
msgstr "Participant"
#: vbv_lernwelt/course/models.py:279
msgid "Experte/Trainer"
msgstr ""
#: vbv_lernwelt/course/models.py:338
#: vbv_lernwelt/course/models.py:339
msgid "Dokumente im Circle ein/aus"
msgstr ""
#: vbv_lernwelt/course/models.py:342
#: vbv_lernwelt/course/models.py:343
msgid "Lernmentor-Funktion ein/aus"
msgstr ""
#: vbv_lernwelt/course/models.py:346
#: vbv_lernwelt/course/models.py:347
msgid "Kompetenzweise ein/aus"
msgstr ""
#: vbv_lernwelt/course/models.py:349
#: vbv_lernwelt/course/models.py:350
msgid "Versicherungsvermittler-Lehrgang"
msgstr ""
#: vbv_lernwelt/course/models.py:350
#: vbv_lernwelt/course/models.py:351
msgid "ÜK-Lehrgang"
msgstr ""
@ -119,34 +120,66 @@ msgstr ""
msgid "export_anwesenheit"
msgstr "export_presence"
#: vbv_lernwelt/course_session/services/export_attendance.py:92
#: vbv_lernwelt/course_session/services/export_attendance.py:86
msgid "Optionale Anwesenheit"
msgstr "Présence facultative"
#: vbv_lernwelt/course_session/services/export_attendance.py:96
msgid "Anwesenheit"
msgstr "Présence"
#: vbv_lernwelt/course_session/services/export_attendance.py:117
#: vbv_lernwelt/course_session/services/export_attendance.py:119
msgid "Ja"
msgstr "Oui"
#: vbv_lernwelt/course_session/services/export_attendance.py:119
msgid "Nein"
msgstr "Non"
#: vbv_lernwelt/course_session/services/export_attendance.py:128
msgid "Anwesend"
msgstr "Présent"
#: vbv_lernwelt/course_session/services/export_attendance.py:117
#: vbv_lernwelt/course_session/services/export_attendance.py:128
msgid "Nicht anwesend"
msgstr "Pas présent"
#: vbv_lernwelt/course_session/services/export_attendance.py:124
#: vbv_lernwelt/course_session/services/export_attendance.py:135
msgid "Vorname"
msgstr "Prénom"
#: vbv_lernwelt/course_session/services/export_attendance.py:125
#: vbv_lernwelt/course_session/services/export_attendance.py:136
msgid "Nachname"
msgstr "Nom de famille"
#: vbv_lernwelt/course_session/services/export_attendance.py:126
#: vbv_lernwelt/course_session/services/export_attendance.py:137
msgid "Email"
msgstr "E-mail"
#: vbv_lernwelt/course_session/services/export_attendance.py:127
#: vbv_lernwelt/course_session/services/export_attendance.py:138
msgid "Lehrvertragsnummer"
msgstr "Numéro de contrat d'apprentissage"
#: vbv_lernwelt/dashboard/person_export.py:16
msgid "export_personen"
msgstr "export_personnes"
#: vbv_lernwelt/dashboard/person_export.py:68
msgid "Telefon"
msgstr "Téléphone"
#: vbv_lernwelt/dashboard/person_export.py:69
msgid "Rolle"
msgstr "Rôle"
#: vbv_lernwelt/dashboard/person_export.py:118
msgid "Trainer"
msgstr "Formateur / Formatrice"
#: vbv_lernwelt/dashboard/person_export.py:120
msgid "Regionenleiter"
msgstr "Responsable CI"
#: vbv_lernwelt/feedback/export.py:19
msgid "export_feedback"
msgstr "export_feedback"

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-07-12 09:36+0200\n"
"POT-Creation-Date: 2024-07-30 11:16+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -88,30 +88,31 @@ msgid "Lehrgang-Seite"
msgstr ""
#: vbv_lernwelt/course/models.py:278
#: vbv_lernwelt/dashboard/person_export.py:116
msgid "Teilnehmer"
msgstr ""
msgstr "Partecipante"
#: vbv_lernwelt/course/models.py:279
msgid "Experte/Trainer"
msgstr ""
#: vbv_lernwelt/course/models.py:338
#: vbv_lernwelt/course/models.py:339
msgid "Dokumente im Circle ein/aus"
msgstr ""
#: vbv_lernwelt/course/models.py:342
#: vbv_lernwelt/course/models.py:343
msgid "Lernmentor-Funktion ein/aus"
msgstr ""
#: vbv_lernwelt/course/models.py:346
#: vbv_lernwelt/course/models.py:347
msgid "Kompetenzweise ein/aus"
msgstr ""
#: vbv_lernwelt/course/models.py:349
#: vbv_lernwelt/course/models.py:350
msgid "Versicherungsvermittler-Lehrgang"
msgstr ""
#: vbv_lernwelt/course/models.py:350
#: vbv_lernwelt/course/models.py:351
msgid "ÜK-Lehrgang"
msgstr ""
@ -119,34 +120,68 @@ msgstr ""
msgid "export_anwesenheit"
msgstr "esportazione_presenza"
#: vbv_lernwelt/course_session/services/export_attendance.py:92
#: vbv_lernwelt/course_session/services/export_attendance.py:86
msgid "Optionale Anwesenheit"
msgstr "Presenza opzionale"
#: vbv_lernwelt/course_session/services/export_attendance.py:96
msgid "Anwesenheit"
msgstr "Presenza"
#: vbv_lernwelt/course_session/services/export_attendance.py:117
#: vbv_lernwelt/course_session/services/export_attendance.py:119
msgid "Ja"
msgstr "Sì"
#: vbv_lernwelt/course_session/services/export_attendance.py:119
msgid "Nein"
msgstr "No"
#: vbv_lernwelt/course_session/services/export_attendance.py:128
msgid "Anwesend"
msgstr "Presente"
#: vbv_lernwelt/course_session/services/export_attendance.py:117
#: vbv_lernwelt/course_session/services/export_attendance.py:128
msgid "Nicht anwesend"
msgstr "Non presente"
#: vbv_lernwelt/course_session/services/export_attendance.py:124
#: vbv_lernwelt/course_session/services/export_attendance.py:135
msgid "Vorname"
msgstr "Nome"
#: vbv_lernwelt/course_session/services/export_attendance.py:125
#: vbv_lernwelt/course_session/services/export_attendance.py:136
msgid "Nachname"
msgstr "Cognome"
#: vbv_lernwelt/course_session/services/export_attendance.py:126
#: vbv_lernwelt/course_session/services/export_attendance.py:137
msgid "Email"
msgstr "Email"
#: vbv_lernwelt/course_session/services/export_attendance.py:127
#: vbv_lernwelt/course_session/services/export_attendance.py:138
msgid "Lehrvertragsnummer"
msgstr "Numero di contratto di tirocinio"
#: vbv_lernwelt/dashboard/person_export.py:16
#, fuzzy
#| msgid "export_anwesenheit"
msgid "export_personen"
msgstr "esportazione_persone"
#: vbv_lernwelt/dashboard/person_export.py:68
msgid "Telefon"
msgstr "Telefono"
#: vbv_lernwelt/dashboard/person_export.py:69
msgid "Rolle"
msgstr "Ruolo"
#: vbv_lernwelt/dashboard/person_export.py:118
msgid "Trainer"
msgstr "Trainer"
#: vbv_lernwelt/dashboard/person_export.py:120
msgid "Regionenleiter"
msgstr "Responsabile CI"
#: vbv_lernwelt/feedback/export.py:19
msgid "export_feedback"
msgstr "esportazione_feedback"

View File

@ -71,5 +71,6 @@ class ProfileViewTest(APITestCase):
"organisation_postal_code": "",
"organisation_city": "",
"organisation_country": None,
"optional_attendance": [],
},
)

View File

@ -17,6 +17,11 @@ def query_competence_course_session_assignments(course_session_ids, circle_ids=N
AssignmentType.CASEWORK.value,
],
learning_content__content_assignment__competence_certificate__isnull=False,
).select_related(
"submission_deadline",
"learning_content",
"course_session",
"learning_content__content_assignment",
):
if circle_ids and csa.learning_content.get_circle().id not in circle_ids:
continue
@ -34,6 +39,11 @@ def query_competence_course_session_edoniq_tests(course_session_ids, circle_ids=
for cset in CourseSessionEdoniqTest.objects.filter(
course_session_id__in=course_session_ids,
learning_content__content_assignment__competence_certificate__isnull=False,
).select_related(
"deadline",
"learning_content",
"course_session",
"learning_content__content_assignment",
):
if circle_ids and cset.learning_content.get_circle().id not in circle_ids:
continue

View File

@ -28,6 +28,7 @@ TEST_MENTOR1_USER_ID = "d1f5f5a9-5b0a-4e1a-9e1a-9e9b5b5e1b1b"
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"
TEST_USER_DATATRANS_HANNA_ID = "6bec1a0d-f852-47aa-a4de-072df6e07ad1"
TEST_COURSE_SESSION_BERN_ID = -1
TEST_COURSE_SESSION_ZURICH_ID = -2

View File

@ -4,6 +4,7 @@ from django.contrib.auth.models import Group, Permission
from django.core.files import File
from environs import Env
from vbv_lernwelt.core.model_utils import add_countries
from vbv_lernwelt.media_files.models import UserImage
env = Env()
@ -20,6 +21,7 @@ from vbv_lernwelt.core.constants import (
TEST_SUPERVISOR1_USER_ID,
TEST_TRAINER1_USER_ID,
TEST_TRAINER2_USER_ID,
TEST_USER_DATATRANS_HANNA_ID,
TEST_USER_EMPTY_ID,
)
from vbv_lernwelt.core.models import User
@ -78,7 +80,30 @@ default_users = [
AVATAR_DIR = settings.APPS_DIR / "static" / "avatars"
def create_datatrans_hanna_user():
hanna, _ = User.objects.get_or_create(
id=TEST_USER_DATATRANS_HANNA_ID,
)
hanna.username = "datatrans.hanna.vbv@example.com"
hanna.email = "datatrans.hanna.vbv@example.com"
hanna.language = "de"
hanna.first_name = "Hanna"
hanna.last_name = "Vbv"
hanna.street = "Bahnstrasse"
hanna.street_number = "2"
hanna.postal_code = "8603"
hanna.city = "Schwerzenbach"
hanna.country_id = "CH"
hanna.birth_date = "1970-01-01"
hanna.phone_number = "+41792018586"
hanna.password = make_password("test")
hanna.save()
return hanna
def create_default_users(default_password="test", set_avatar=False):
add_countries(small_set=True)
admin_group, created = Group.objects.get_or_create(name="admin_group")
_content_creator_group, _created = Group.objects.get_or_create(
name="content_creator_grop"
@ -202,6 +227,8 @@ def create_default_users(default_password="test", set_avatar=False):
language="de",
)
hanna = create_datatrans_hanna_user()
for user_data in default_users:
_create_student_user(**user_data)

View File

@ -17,8 +17,10 @@ from vbv_lernwelt.core.constants import (
TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID,
TEST_STUDENT3_USER_ID,
TEST_TRAINER1_USER_ID,
TEST_USER_DATATRANS_HANNA_ID,
TEST_USER_EMPTY_ID,
)
from vbv_lernwelt.core.create_default_users import create_datatrans_hanna_user
from vbv_lernwelt.core.models import Organisation, User
from vbv_lernwelt.course.consts import (
COURSE_TEST_ID,
@ -148,6 +150,11 @@ from vbv_lernwelt.shop.models import CheckoutInformation
default=False,
help="Will set only the is_vv flag for the test course and enable learning mentors for the course",
)
@click.option(
"--set-optional-attendance-flag/--no-optional-attendance-flag",
default=False,
help="Will set the optional attendance flag for the test-student1@example.com",
)
def command(
create_assignment_completion,
assignment_completion_user_id,
@ -167,6 +174,7 @@ def command(
create_learning_mentor,
set_only_is_uk_flag,
set_only_is_vv_flag,
set_optional_attendance_flag,
):
print("cypress reset data")
CourseCompletion.objects.all().delete()
@ -195,6 +203,9 @@ def command(
password=make_password("test"),
)
User.objects.filter(id=TEST_USER_DATATRANS_HANNA_ID).delete()
create_datatrans_hanna_user()
cursor = connection.cursor()
cursor.execute("truncate core_securityrequestresponselog;")
cursor.execute("truncate core_externalapirequestlog;")
@ -515,4 +526,17 @@ def command(
course.configuration.is_uk = False
course.configuration.enable_learning_mentor = False
if set_optional_attendance_flag:
course_session_user = CourseSessionUser.objects.get(
user__id=TEST_STUDENT1_USER_ID
)
course_session_user.optional_attendance = True
course_session_user.save()
else:
course_session_user = CourseSessionUser.objects.get(
user_id=TEST_STUDENT1_USER_ID
)
course_session_user.optional_attendance = False
course_session_user.save()
course.configuration.save()

View File

@ -114,8 +114,9 @@ class User(AbstractUser):
blank=True,
)
# fields gathered from cembra pay form
birth_date = models.DateField(null=True, blank=True)
# phone number should be stored in the format +41792018586 (not validated)
phone_number = models.CharField(max_length=255, blank=True, default="")
# is only set by abacus invoice export code

View File

@ -55,6 +55,7 @@ class UserSerializer(serializers.ModelSerializer):
course_session_experts = serializers.SerializerMethodField()
country = CountrySerializer()
organisation_country = CountrySerializer()
optional_attendance = serializers.SerializerMethodField()
class Meta:
model = User
@ -83,6 +84,7 @@ class UserSerializer(serializers.ModelSerializer):
"organisation_postal_code",
"organisation_city",
"organisation_country",
"optional_attendance",
]
read_only_fields = [
"id",
@ -108,6 +110,12 @@ class UserSerializer(serializers.ModelSerializer):
return [str(_id) for _id in (supervisor_in_session_ids | expert_in_session_ids)]
def get_optional_attendance(self, obj: User) -> bool:
optional_attendance_ids = CourseSessionUser.objects.filter(
user=obj, optional_attendance=True
).values_list("course_session__id", flat=True)
return [str(id) for id in optional_attendance_ids]
def update(self, instance, validated_data):
country_data = validated_data.pop("country", None)
organisation_country_data = validated_data.pop("organisation_country", None)
@ -131,6 +139,12 @@ class UserSerializer(serializers.ModelSerializer):
return instance
class CypressUserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = "__all__"
class OrganisationSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source="organisation_id", read_only=True)
name = serializers.SerializerMethodField()

View File

@ -64,6 +64,7 @@ class CourseSessionUserAdmin(admin.ModelAdmin):
"course_session",
"role",
"circles",
"optional_attendance",
# "created_at",
# "updated_at",
]
@ -76,6 +77,7 @@ class CourseSessionUserAdmin(admin.ModelAdmin):
list_filter = [
"role",
"course_session",
"optional_attendance",
]
raw_id_fields = [
"user",
@ -97,7 +99,7 @@ class CourseSessionUserAdmin(admin.ModelAdmin):
return ", ".join([c.title for c in obj.expert.all()])
fieldsets = [
(None, {"fields": ("user", "course_session", "role")}),
(None, {"fields": ("user", "course_session", "role", "optional_attendance")}),
(
"Expert/Trainer",
{

View File

@ -28,3 +28,10 @@ UK_COURSE_IDS = [
COURSE_UK_TRAINING_FR,
COURSE_UK_TRAINING_IT,
]
# Organization IDs
ORGANISATION_OTHER_BROKER_ID = 1
ORGANISATION_OTHER_HEALTH_INSURANCE_ID = 2
ORGANISATION_OTHER_PRIVATE_INSURANCE_ID = 3
ORGANISATION_NO_COMPANY_ID = 31

View File

@ -151,6 +151,7 @@ class CourseSessionUserObjectsType(ObjectType):
circles = graphene.List(
graphene.NonNull(CourseSessionUserExpertCircleType), required=True
)
optional_attendance = graphene.Boolean(required=False)
class CircleDocumentObjectType(DjangoObjectType):
@ -233,6 +234,7 @@ class CourseSessionObjectType(DjangoObjectType):
)
for circle in course_session_user.expert.all() # noqa
],
optional_attendance=course_session_user.optional_attendance, # noqa
)
)

View File

@ -0,0 +1,32 @@
# Generated by Django 4.2.13 on 2024-07-25 05:47
from django.db import migrations, models
import vbv_lernwelt.course.models
class Migration(migrations.Migration):
dependencies = [
("course", "0008_auto_20240403_1132"),
]
operations = [
migrations.AddField(
model_name="coursesessionuser",
name="optional_attendance",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="coursecompletion",
name="completion_status",
field=models.CharField(
choices=[
("SUCCESS", "Success"),
("FAIL", "Fail"),
("UNKNOWN", "Unknown"),
],
default=vbv_lernwelt.course.models.CourseCompletionStatus["UNKNOWN"],
max_length=255,
),
),
]

View File

@ -283,6 +283,7 @@ class CourseSessionUser(models.Model):
expert = models.ManyToManyField(
"learnpath.Circle", related_name="expert", blank=True
)
optional_attendance = models.BooleanField(default=False)
class Meta:
constraints = [

View File

@ -0,0 +1,30 @@
import djclick as click
import structlog
from django.db.models import Count
from vbv_lernwelt.course.models import CourseSessionUser
logger = structlog.get_logger(__name__)
@click.command()
def command():
VV_COURSE_SESSIONS = [1, 2, 3] # DE, FR, IT
# Aggregation of users per organisation for a specific course session
user_counts = (
CourseSessionUser.objects.filter(course_session__id__in=VV_COURSE_SESSIONS)
.values("user__organisation__organisation_id", "user__organisation__name_de")
.annotate(user_count=Count("id"))
.order_by("user__organisation__organisation_id")
)
for entry in user_counts:
print(
f"Organisation Name: {entry['user__organisation__name_de']}, "
f"User Count: {entry['user_count']}"
)
print(
f"Total number of users: {sum([entry['user_count'] for entry in user_counts])}"
)

View File

@ -82,6 +82,10 @@ def _create_sheet(
# headers
# common user headers..., <attendance_course> <date>, status <attendance_course>, ..
col_idx = add_user_headers(sheet)
sheet.cell(row=1, column=col_idx, value=str(_("Optionale Anwesenheit")))
col_idx += 1
attendance_data = {}
for course in attendance_courses:
@ -110,6 +114,13 @@ def _create_sheet(
def _add_rows(sheet, users: list[CourseSessionUser], attendance_data):
for row_idx, user in enumerate(users, start=2):
col_idx = add_user_export_data(sheet, user, row_idx)
optional_attendance_text = (
str(_("Ja")) if user.optional_attendance else str(_("Nein"))
)
sheet.cell(row=row_idx, column=col_idx, value=optional_attendance_text)
col_idx += 1
for key, user_dict_map in attendance_data.items():
user_dict = user_dict_map.get(str(user.user.id), {})
status = user_dict.get("status", "") if user_dict else ""

View File

@ -8,7 +8,7 @@ from vbv_lernwelt.core.constants import TEST_STUDENT1_USER_ID, TEST_STUDENT2_USE
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.creators.test_course import create_test_course
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.course_session.services.export_attendance import export_attendance
@ -42,6 +42,11 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.test_student1 = User.objects.get(id=TEST_STUDENT1_USER_ID)
self.test_student1.additional_json_data = {"Lehrvertragsnummer": 1234567890}
self.test_student1.save()
csu1 = CourseSessionUser.objects.get(
user=self.test_student1, course_session=self.course_session_be
)
csu1.optional_attendance = True
csu1.save()
self.test_student2 = User.objects.get(id=TEST_STUDENT2_USER_ID)
self.test_student2.additional_json_data = {"Lehrvertragsnummer": 1987654321}
self.test_student2.save()
@ -64,6 +69,7 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.test_student1.last_name,
self.test_student1.email,
self.test_student1.additional_json_data["Lehrvertragsnummer"],
"Ja",
"Anwesend",
],
[
@ -71,6 +77,7 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.test_student2.last_name,
self.test_student2.email,
self.test_student2.additional_json_data["Lehrvertragsnummer"],
"Nein",
"Nicht anwesend",
],
[
@ -78,6 +85,7 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.test_student3.last_name,
self.test_student3.email,
None,
"Nein",
"Nicht anwesend",
],
]
@ -95,6 +103,7 @@ class AttendanceExportTestCase(ExportBaseTestCase):
"Nachname",
"Email",
"Lehrvertragsnummer",
"Optionale Anwesenheit",
f"Anwesenheit {csac.get_circle().title} {csac.due_date.start.strftime('%d.%m.%Y')}",
]
@ -103,7 +112,7 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.assertEqual(len(wb.sheetnames), 1)
self.assertEqual(wb.sheetnames[0], "Test Bern 2022 a")
self._check_export(wb, self.expected_data_be, 4, 5)
self._check_export(wb, self.expected_data_be, 4, 6)
def test_attendance_export_multiple_cs(self):
self.attendance_course_zh.attendance_user_list = [
@ -123,6 +132,7 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.test_student2.last_name,
self.test_student2.email,
self.test_student2.additional_json_data["Lehrvertragsnummer"],
"Nein",
"Anwesend",
],
]
@ -136,10 +146,10 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.assertEqual(wb.sheetnames[0], "Test Bern 2022 a")
self.assertEqual(wb.sheetnames[1], "Test Zürich 2022 a")
self._check_export(wb, self.expected_data_be, 4, 5)
self._check_export(wb, self.expected_data_be, 4, 6)
wb.active = wb["Test Zürich 2022 a"]
self._check_export(wb, expected_data_zh, 2, 5)
self._check_export(wb, expected_data_zh, 2, 6)
def test_french_export(self):
activate("fr")
@ -150,6 +160,7 @@ class AttendanceExportTestCase(ExportBaseTestCase):
"Nom de famille",
"E-mail",
"Numéro de contrat d'apprentissage",
"Présence facultative",
f"Présence {self.attendance_course_be.get_circle().title} {self.attendance_course_be.due_date.start.strftime('%d.%m.%Y')}",
]
@ -160,6 +171,7 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.test_student1.last_name,
self.test_student1.email,
self.test_student1.additional_json_data["Lehrvertragsnummer"],
"Oui",
"Présent",
],
[
@ -167,6 +179,7 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.test_student2.last_name,
self.test_student2.email,
self.test_student2.additional_json_data["Lehrvertragsnummer"],
"Non",
"Pas présent",
],
[
@ -174,10 +187,11 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.test_student3.last_name,
self.test_student3.email,
None,
"Non",
"Pas présent",
],
]
self._check_export(wb, expected_data_be, 4, 5)
self._check_export(wb, expected_data_be, 4, 6)
def test_italian_export(self):
activate("it")
@ -188,6 +202,7 @@ class AttendanceExportTestCase(ExportBaseTestCase):
"Cognome",
"Email",
"Numero di contratto di tirocinio",
"Presenza opzionale",
f"Presenza {self.attendance_course_be.get_circle().title} {self.attendance_course_be.due_date.start.strftime('%d.%m.%Y')}",
]
@ -198,6 +213,7 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.test_student1.last_name,
self.test_student1.email,
self.test_student1.additional_json_data["Lehrvertragsnummer"],
"",
"Presente",
],
[
@ -205,6 +221,7 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.test_student2.last_name,
self.test_student2.email,
self.test_student2.additional_json_data["Lehrvertragsnummer"],
"No",
"Non presente",
],
[
@ -212,7 +229,8 @@ class AttendanceExportTestCase(ExportBaseTestCase):
self.test_student3.last_name,
self.test_student3.email,
None,
"No",
"Non presente",
],
]
self._check_export(wb, expected_data_be, 4, 5)
self._check_export(wb, expected_data_be, 4, 6)

View File

@ -1,5 +1,5 @@
import math
from typing import List
from typing import List, Tuple
import graphene
@ -7,7 +7,6 @@ import vbv_lernwelt.assignment.models
from vbv_lernwelt.assignment.models import (
AssignmentCompletion,
AssignmentCompletionStatus,
AssignmentType,
)
from vbv_lernwelt.competence.services import (
query_competence_course_session_assignments,
@ -98,14 +97,23 @@ def get_assignment_completion_metrics(
assignment: vbv_lernwelt.assignment.models.Assignment,
user_selection_ids: List[str] | None,
urql_id_postfix: str = "",
context=None,
) -> AssignmentCompletionMetricsType:
if not context:
context = {}
if user_selection_ids:
course_session_users = user_selection_ids
else:
course_session_users = CourseSessionUser.objects.filter(
course_session=course_session,
role=CourseSessionUser.Role.MEMBER,
).values_list("user", flat=True)
key = f"CourseSessionUser_{course_session.id}"
if not key in context:
course_session_users = CourseSessionUser.objects.filter(
course_session=course_session,
role=CourseSessionUser.Role.MEMBER,
).values_list("user", flat=True)
context[key] = course_session_users
else:
course_session_users = context[key]
evaluation_results = AssignmentCompletion.objects.filter(
completion_status=AssignmentCompletionStatus.EVALUATION_SUBMITTED.value,
@ -139,33 +147,54 @@ def create_record(
course_session_assignment: CourseSessionAssignment | CourseSessionEdoniqTest,
user_selection_ids: List[str] | None,
urql_id_postfix: str = "",
) -> AssignmentStatisticsRecordType:
if isinstance(course_session_assignment, CourseSessionAssignment):
due_date = course_session_assignment.submission_deadline
else:
due_date = course_session_assignment.deadline
context=None,
) -> Tuple[AssignmentStatisticsRecordType, dict]:
if not context:
context = {}
assignment_type = (
"CourseSessionAssignment"
if isinstance(course_session_assignment, CourseSessionAssignment)
else "CourseSessionEdoniqTest"
)
due_date = (
course_session_assignment.submission_deadline
if assignment_type == "CourseSessionAssignment"
else course_session_assignment.deadline
)
key = f"{assignment_type}_{course_session_assignment.learning_content.id}"
if not key in context:
context[key] = course_session_assignment.learning_content.get_circle().id
circle_id = context[key]
learning_content = course_session_assignment.learning_content
return AssignmentStatisticsRecordType(
# make sure it's unique, across all types of assignments!
_id=f"{course_session_assignment._meta.model_name}#{course_session_assignment.id}@{urql_id_postfix}",
# noqa
course_session_id=str(course_session_assignment.course_session.id), # noqa
circle_id=learning_content.get_circle().id, # noqa
course_session_assignment_id=str(course_session_assignment.id), # noqa
generation=course_session_assignment.course_session.generation, # noqa
assignment_type_translation_key=due_date.assignment_type_translation_key,
# noqa
assignment_title=learning_content.content_assignment.title, # noqa
metrics=get_assignment_completion_metrics( # noqa
course_session=course_session_assignment.course_session, # noqa
assignment=learning_content.content_assignment, # noqa
user_selection_ids=user_selection_ids, # noqa
urql_id_postfix=urql_id_postfix, # noqa
return (
AssignmentStatisticsRecordType(
# make sure it's unique, across all types of assignments!
_id=f"{course_session_assignment._meta.model_name}#{course_session_assignment.id}@{urql_id_postfix}",
# noqa
course_session_id=str(course_session_assignment.course_session.id), # noqa
circle_id=circle_id, # noqa
course_session_assignment_id=str(course_session_assignment.id), # noqa
generation=course_session_assignment.course_session.generation, # noqa
assignment_type_translation_key=due_date.assignment_type_translation_key,
# noqa
assignment_title=learning_content.content_assignment.title, # noqa
metrics=get_assignment_completion_metrics( # noqa
course_session=course_session_assignment.course_session, # noqa
assignment=learning_content.content_assignment, # noqa
user_selection_ids=user_selection_ids, # noqa
urql_id_postfix=urql_id_postfix, # noqa
context=context, # noqa
),
details_url=due_date.url_expert, # noqa
deadline=due_date.start, # noqa
),
details_url=due_date.url_expert, # noqa
deadline=due_date.start, # noqa
context,
)
@ -178,28 +207,26 @@ def assignments(
) -> AssignmentsStatisticsType:
if urql_id is None:
urql_id = str(course_id)
course_sessions = CourseSession.objects.filter(
id__in=course_session_selection_ids,
)
records: List[AssignmentStatisticsRecordType] = []
context = {}
for course_session in course_sessions:
for csa in query_competence_course_session_assignments(
[course_session.id], circle_ids
):
record = create_record(csa, user_selection_ids, urql_id_postfix=urql_id)
records.append(record)
csas = query_competence_course_session_assignments(course_sessions, circle_ids)
csets = query_competence_course_session_edoniq_tests(course_sessions, circle_ids)
for cset in query_competence_course_session_edoniq_tests(
[course_session.id], circle_ids
):
record = create_record(
course_session_assignment=cset,
user_selection_ids=user_selection_ids,
urql_id_postfix=urql_id,
)
records.append(record)
for csa in csas:
record, context = create_record(
csa, user_selection_ids, urql_id_postfix=urql_id, context=context
)
records.append(record)
for cset in csets:
record, context = create_record(
cset, user_selection_ids, urql_id_postfix=urql_id, context=context
)
records.append(record)
return AssignmentsStatisticsType(
_id=urql_id, # noqa

View File

@ -41,25 +41,23 @@ def competences(
completions = CourseCompletion.objects.filter(
course_session_id__in=course_session_selection_ids,
page_type="competence.PerformanceCriteria",
).prefetch_related("course_session", "page")
).select_related("course_session", "page")
if user_selection_ids is not None:
completions = completions.filter(user_id__in=user_selection_ids)
competence_records = {}
page_ids = completions.values_list("page_id", flat=True).distinct()
pages = Page.objects.filter(id__in=page_ids).specific()
learning_units = {page.id: page.specific.learning_unit for page in pages}
unique_page_ids = {completion.page.id for completion in completions}
learning_units = {
page_id: Page.objects.get(id=page_id).specific.learning_unit
for page_id in unique_page_ids
}
circles = {
lu.id: c
for lu in learning_units.values()
if (lu is not None and (c := lu.get_circle()) is not None)
and (circle_ids is None or c.id in circle_ids)
if lu and (c := lu.get_circle()) and (circle_ids is None or c.id in circle_ids)
}
competence_records = {}
for completion in completions:
learning_unit = learning_units.get(completion.page.id)
@ -73,20 +71,23 @@ def competences(
combined_id = f"{circle.id}-{completion.course_session.id}@{urql_id_postfix}"
competence_records.setdefault(combined_id, {}).setdefault(
learning_unit,
CompetenceRecordStatisticsType(
_id=combined_id, # noqa
title=learning_unit.title, # noqa
course_session_id=completion.course_session.id, # noqa
generation=completion.course_session.generation, # noqa
circle_id=circle.id, # noqa
success_count=0, # noqa
fail_count=0, # noqa
if combined_id not in competence_records:
competence_records[combined_id] = {}
if learning_unit not in competence_records[combined_id]:
competence_records[combined_id][
learning_unit
] = CompetenceRecordStatisticsType(
_id=combined_id,
title=learning_unit.title,
course_session_id=completion.course_session.id,
generation=completion.course_session.generation,
circle_id=circle.id,
success_count=0,
fail_count=0,
details_url=f"/course/{course_slug}/cockpit?courseSessionId={completion.course_session.id}",
# noqa
),
)
)
if completion.completion_status == CourseCompletionStatus.SUCCESS.value:
competence_records[combined_id][learning_unit].success_count += 1
@ -99,7 +100,7 @@ def competences(
for record in circle_records.values()
]
success_count = sum([c.success_count for c in values])
fail_count = sum([c.fail_count for c in values])
success_count = sum(c.success_count for c in values)
fail_count = sum(c.fail_count for c in values)
return values, success_count, fail_count

View File

@ -0,0 +1,123 @@
from io import BytesIO
from typing import Optional
import structlog
from django.utils.translation import gettext_lazy as _
from openpyxl import Workbook
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course_session.services.export_attendance import (
add_user_headers,
make_export_filename,
sanitize_sheet_name,
)
from vbv_lernwelt.dashboard.utils import create_person_list_with_roles
PERSONS_EXPORT_FILENAME = _("export_personen")
logger = structlog.get_logger(__name__)
def export_persons(
user: User,
course_session_ids: list[str],
save_as_file: bool = False,
) -> Optional[bytes]:
if not course_session_ids:
return
wb = Workbook()
# remove the first sheet is just easier than keeping track of the active sheet
wb.remove(wb.active)
user_with_roles = create_person_list_with_roles(user, course_session_ids)
course_sessions = CourseSession.objects.filter(id__in=course_session_ids)
for cs in course_sessions:
_create_sheet(
wb,
cs.title,
cs.id,
user_with_roles,
)
if save_as_file:
wb.save(make_export_filename(PERSONS_EXPORT_FILENAME))
else:
output = BytesIO()
wb.save(output)
output.seek(0)
return output.getvalue()
def _create_sheet(
wb: Workbook,
title: str,
cs_id: int,
user_with_roles,
):
sheet = wb.create_sheet(title=sanitize_sheet_name(title))
if len(user_with_roles) == 0:
return sheet
# headers
# common user headers, Circle <title> <learningcontenttitle> bestanden, Circle <title> <learningcontenttitle> Resultat, ...
col_idx = add_user_headers(sheet)
sheet.cell(row=1, column=col_idx, value=str(_("Telefon")))
sheet.cell(row=1, column=col_idx + 1, value=str(_("Rolle")))
_add_rows(sheet, user_with_roles, cs_id)
return sheet
def _add_rows(
sheet,
users,
course_session_id,
):
idx_offset = 0
for row_idx, user in enumerate(users, start=2):
def get_user_cs_by_id(user_cs, cs_id):
return next((cs for cs in user_cs if int(cs.get("id")) == cs_id), None)
user_cs = get_user_cs_by_id(user["course_sessions"], course_session_id)
if not user_cs:
logger.warning(
"User not found in course session",
user_id=user["user_id"],
course_session_id=course_session_id,
)
idx_offset += 1
continue
user_role = _role_as_string(user_cs.get("user_role"))
idx = row_idx - idx_offset
sheet.cell(row=idx, column=1, value=user["first_name"])
sheet.cell(row=idx, column=2, value=user["last_name"])
sheet.cell(row=idx, column=3, value=user["email"])
sheet.cell(row=idx, column=4, value=user.get("Lehrvertragsnummer", ""))
sheet.cell(
row=idx,
column=5,
value=user.get("phone_number", ""),
)
sheet.cell(row=idx, column=6, value=user_role)
def _role_as_string(role):
if role == "MEMBER":
return str(_("Teilnehmer"))
elif role == "EXPERT":
return str(_("Trainer"))
elif role == "SUPERVISOR":
return str(_("Regionenleiter"))
else:
return role

View File

@ -0,0 +1,186 @@
import io
from django.utils.translation import activate
from openpyxl import load_workbook
from vbv_lernwelt.core.constants import (
TEST_STUDENT1_USER_ID,
TEST_STUDENT2_USER_ID,
TEST_STUDENT3_USER_ID,
TEST_TRAINER1_USER_ID,
TEST_TRAINER2_USER_ID,
)
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.creators.test_course import create_test_course
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course_session.tests.test_attendance_export import ExportBaseTestCase
from vbv_lernwelt.dashboard.person_export import export_persons
from vbv_lernwelt.learnpath.models import Circle
class PersonsExportTestCase(ExportBaseTestCase):
def setUp(self):
super().setUp()
create_default_users()
create_test_course(include_vv=False, with_sessions=True)
self.course_session_be = CourseSession.objects.get(title="Test Bern 2022 a")
self.course_session_zh = CourseSession.objects.get(title="Test Zürich 2022 a")
self.circle_fahrzeug = Circle.objects.get(
slug="test-lehrgang-lp-circle-fahrzeug"
)
self.circle_reisen = Circle.objects.get(slug="test-lehrgang-lp-circle-fahrzeug")
self.test_trainer1 = User.objects.get(id=TEST_TRAINER1_USER_ID)
self.test_trainer2 = User.objects.get(id=TEST_TRAINER2_USER_ID)
self.test_student1 = User.objects.get(id=TEST_STUDENT1_USER_ID)
self.test_student2 = User.objects.get(id=TEST_STUDENT2_USER_ID)
self.test_student3 = User.objects.get(id=TEST_STUDENT3_USER_ID)
self.test_student1_row = [
self.test_student1.first_name,
self.test_student1.last_name,
self.test_student1.email,
None,
None,
"Teilnehmer",
]
self.test_student2_row = [
self.test_student2.first_name,
self.test_student2.last_name,
self.test_student2.email,
None,
None,
"Teilnehmer",
]
self.test_student3_row = [
self.test_student3.first_name,
self.test_student3.last_name,
self.test_student3.email,
None,
None,
"Teilnehmer",
]
self.test_trainer1_row = [
self.test_trainer1.first_name,
self.test_trainer1.last_name,
self.test_trainer1.email,
None,
None,
"Trainer",
]
self.test_trainer2_row = [
self.test_trainer2.first_name,
self.test_trainer2.last_name,
self.test_trainer2.email,
None,
None,
"Trainer",
]
def _generate_expected_data(self, rows):
expected_data = [
self._make_header(),
]
for r in rows:
expected_data.append(r)
return expected_data
def _generate_workbook(self, user, course_session_ids):
export_data = io.BytesIO(
export_persons(user, course_session_ids, save_as_file=False)
)
return load_workbook(export_data)
def _make_header(self):
return [
"Vorname",
"Nachname",
"Email",
"Lehrvertragsnummer",
"Telefon",
"Rolle",
]
def test_export_persons(self):
wb = self._generate_workbook(self.test_trainer1, [self.course_session_be.id])
self.assertEqual(len(wb.sheetnames), 1)
self.assertEqual(wb.sheetnames[0], "Test Bern 2022 a")
wb.active = wb["Test Bern 2022 a"]
data = self._generate_expected_data(
[
self.test_student1_row,
self.test_student2_row,
self.test_student3_row,
self.test_trainer1_row,
]
)
self._check_export(wb, data, 4, 6)
wb = self._generate_workbook(self.test_trainer2, [self.course_session_zh.id])
self.assertEqual(len(wb.sheetnames), 1)
self.assertEqual(wb.sheetnames[0], "Test Zürich 2022 a")
wb.active = wb["Test Zürich 2022 a"]
data = self._generate_expected_data(
[self.test_student2_row, self.test_trainer2_row]
)
self._check_export(wb, data, 3, 6)
def test_cannot_export_other_session(self):
wb = self._generate_workbook(self.test_trainer1, [self.course_session_zh.id])
self.assertEqual(len(wb.sheetnames), 1)
self.assertEqual(wb.sheetnames[0], "Test Zürich 2022 a")
wb.active = wb["Test Zürich 2022 a"]
data = self._generate_expected_data([[None] * 6])
self._check_export(wb, data, 1, 6)
def test_export_in_fr(self):
activate("fr")
wb = self._generate_workbook(self.test_trainer1, [self.course_session_be.id])
self.assertEqual(len(wb.sheetnames), 1)
self.assertEqual(wb.sheetnames[0], "Test Bern 2022 a")
wb.active = wb["Test Bern 2022 a"]
header = [
"Prénom",
"Nom de famille",
"E-mail",
"Numéro de contrat d'apprentissage",
"Téléphone",
"Rôle",
]
self.assertEqual([cell.value for cell in wb.active[1]], header)
self.assertEqual(wb.active.cell(row=2, column=6).value, "Participant")
self.assertEqual(
wb.active.cell(row=5, column=6).value, "Formateur / Formatrice"
)
def test_export_in_it(self):
activate("it")
wb = self._generate_workbook(self.test_trainer1, [self.course_session_be.id])
self.assertEqual(len(wb.sheetnames), 1)
self.assertEqual(wb.sheetnames[0], "Test Bern 2022 a")
wb.active = wb["Test Bern 2022 a"]
header = [
"Nome",
"Cognome",
"Email",
"Numero di contratto di tirocinio",
"Telefono",
"Ruolo",
]
self.assertEqual([cell.value for cell in wb.active[1]], header)
self.assertEqual(wb.active.cell(row=2, column=6).value, "Partecipante")
self.assertEqual(wb.active.cell(row=5, column=6).value, "Trainer")

View File

@ -0,0 +1,179 @@
from dataclasses import dataclass
from typing import List, Set
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.learning_mentor.models import LearningMentor
@dataclass(frozen=True)
class CourseSessionWithRoles:
_original: CourseSession
roles: Set[str]
def __getattr__(self, name: str):
# Delegate attribute access to the _original CourseSession object
return getattr(self._original, name)
def save(self, *args, **kwargs):
raise NotImplementedError("This proxy object cannot be saved.")
def get_course_sessions_with_roles_for_user(user: User) -> List[CourseSessionWithRoles]:
result_course_sessions = {}
# participant/member/expert course sessions
csu_qs = CourseSessionUser.objects.filter(user=user).prefetch_related(
"course_session", "course_session__course"
)
for csu in csu_qs:
cs = csu.course_session
# member/expert is mutually exclusive...
cs.roles = {csu.role}
result_course_sessions[cs.id] = cs
# enrich with supervisor course sessions
csg_qs = CourseSessionGroup.objects.filter(supervisor=user).prefetch_related(
"course_session", "course_session__course"
)
for csg in csg_qs:
for cs in csg.course_session.all():
cs.roles = set()
cs = result_course_sessions.get(cs.id, cs)
cs.roles.add("SUPERVISOR")
result_course_sessions[cs.id] = cs
# enrich with mentor course sessions
lm_qs = LearningMentor.objects.filter(mentor=user).prefetch_related(
"course_session", "course_session__course"
)
for lm in lm_qs:
cs = lm.course_session
cs.roles = set()
cs = result_course_sessions.get(cs.id, cs)
cs.roles.add("LEARNING_MENTOR")
result_course_sessions[cs.id] = cs
return [
CourseSessionWithRoles(cs, cs.roles) for cs in result_course_sessions.values()
]
def has_cs_role(roles: Set[str]) -> bool:
return bool(roles & {"SUPERVISOR", "EXPERT", "MEMBER"})
def user_role(roles: Set[str]) -> str:
if "SUPERVISOR" in roles:
return "SUPERVISOR"
if "EXPERT" in roles:
return "EXPERT"
if "MEMBER" in roles:
return "MEMBER"
return "LEARNING_MENTOR"
def create_course_session_dict(course_session_object, my_role, user_role):
return {
"id": str(course_session_object.id),
"session_title": course_session_object.title,
"course_id": str(course_session_object.course.id),
"course_title": course_session_object.course.title,
"course_slug": course_session_object.course.slug,
"region": course_session_object.region,
"generation": course_session_object.generation,
"my_role": my_role,
"user_role": user_role,
"is_uk": course_session_object.course.configuration.is_uk,
"is_vv": course_session_object.course.configuration.is_vv,
}
def create_person_list_with_roles(
user, course_session_ids=None, include_private_data=False
):
def create_user_dict(user_object):
user_data = {
"user_id": user_object.id,
"first_name": user_object.first_name,
"last_name": user_object.last_name,
"email": user_object.email,
"avatar_url_small": user_object.avatar_url_small,
"avatar_url": user_object.avatar_url,
"course_sessions": [],
}
if include_private_data:
user_data["phone_number"] = user_object.phone_number
user_data["Lehrvertragsnummer"] = user_object.additional_json_data.get(
"Lehrvertragsnummer", ""
)
return user_data
course_sessions = get_course_sessions_with_roles_for_user(user)
result_persons = {}
for cs in course_sessions:
if has_cs_role(cs.roles) and cs.course.configuration.is_uk:
course_session_users = CourseSessionUser.objects.filter(
course_session=cs.id
).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["course_sessions"].append(
create_course_session_dict(cs, my_role, csu.role)
)
result_persons[csu.user.id] = person_data
# add persons where request.user is mentor
for cs in course_sessions:
if "LEARNING_MENTOR" in cs.roles:
lm = LearningMentor.objects.filter(
mentor=user, course_session=cs.id
).first()
for participant in lm.participants.all():
course_session_entry = create_course_session_dict(
cs,
"LEARNING_MENTOR",
"LEARNING_MENTEE",
)
if participant.user.id not in result_persons:
person_data = create_user_dict(participant.user)
person_data["course_sessions"] = [course_session_entry]
result_persons[participant.user.id] = person_data
else:
# user is already in result_persons
result_persons[participant.user.id]["course_sessions"].append(
course_session_entry
)
# add persons where request.user is mentee
mentor_relation_qs = LearningMentor.objects.filter(
participants__user=user
).prefetch_related("mentor", "course_session")
for mentor_relation in mentor_relation_qs:
cs = mentor_relation.course_session
course_session_entry = create_course_session_dict(
cs,
"LEARNING_MENTEE",
"LEARNING_MENTOR",
)
if mentor_relation.mentor.id not in result_persons:
person_data = create_user_dict(mentor_relation.mentor)
person_data["course_sessions"] = [course_session_entry]
result_persons[mentor_relation.mentor.id] = person_data
else:
# user is already in result_persons
result_persons[mentor_relation.mentor.id]["course_sessions"].append(
course_session_entry
)
return result_persons.values()

View File

@ -2,7 +2,7 @@ import base64
from dataclasses import asdict, dataclass
from datetime import date
from enum import Enum
from typing import List, Set, Tuple
from typing import List, Tuple
from django.db.models import Q
from django.http import HttpResponse
@ -24,25 +24,27 @@ from vbv_lernwelt.competence.services import (
query_competence_course_session_edoniq_tests,
)
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import (
CourseConfiguration,
CourseSession,
CourseSessionUser,
)
from vbv_lernwelt.course.models import CourseConfiguration, CourseSessionUser
from vbv_lernwelt.course.views import logger
from vbv_lernwelt.course_session.services.export_attendance import (
ATTENDANCE_EXPORT_FILENAME,
export_attendance,
make_export_filename,
)
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.dashboard.person_export import export_persons, PERSONS_EXPORT_FILENAME
from vbv_lernwelt.dashboard.utils import (
CourseSessionWithRoles,
create_course_session_dict,
create_person_list_with_roles,
get_course_sessions_with_roles_for_user,
user_role,
)
from vbv_lernwelt.duedate.models import DueDate
from vbv_lernwelt.duedate.serializers import DueDateSerializer
from vbv_lernwelt.feedback.export import (
export_feedback_with_circle_restriction,
FEEDBACK_EXPORT_FILE_NAME,
)
from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.learnpath.models import Circle
from vbv_lernwelt.self_evaluation_feedback.models import SelfEvaluationFeedback
@ -65,19 +67,6 @@ class RoleKeyType(Enum):
TRAINER = "Trainer"
@dataclass(frozen=True)
class CourseSessionWithRoles:
_original: CourseSession
roles: Set[str]
def __getattr__(self, name: str):
# Delegate attribute access to the _original CourseSession object
return getattr(self._original, name)
def save(self, *args, **kwargs):
raise NotImplementedError("This proxy object cannot be saved.")
@dataclass(frozen=True)
class CourseConfig:
course_id: str
@ -92,157 +81,6 @@ class CourseConfig:
session_to_continue_id: str | None
def get_course_sessions_with_roles_for_user(user: User) -> List[CourseSessionWithRoles]:
result_course_sessions = {}
# participant/member/expert course sessions
csu_qs = CourseSessionUser.objects.filter(user=user).prefetch_related(
"course_session", "course_session__course"
)
for csu in csu_qs:
cs = csu.course_session
# member/expert is mutually exclusive...
cs.roles = {csu.role}
result_course_sessions[cs.id] = cs
# enrich with supervisor course sessions
csg_qs = CourseSessionGroup.objects.filter(supervisor=user).prefetch_related(
"course_session", "course_session__course"
)
for csg in csg_qs:
for cs in csg.course_session.all():
cs.roles = set()
cs = result_course_sessions.get(cs.id, cs)
cs.roles.add("SUPERVISOR")
result_course_sessions[cs.id] = cs
# enrich with mentor course sessions
lm_qs = LearningMentor.objects.filter(mentor=user).prefetch_related(
"course_session", "course_session__course"
)
for lm in lm_qs:
cs = lm.course_session
cs.roles = set()
cs = result_course_sessions.get(cs.id, cs)
cs.roles.add("LEARNING_MENTOR")
result_course_sessions[cs.id] = cs
return [
CourseSessionWithRoles(cs, cs.roles) for cs in result_course_sessions.values()
]
def has_cs_role(roles: Set[str]) -> bool:
return bool(roles & {"SUPERVISOR", "EXPERT", "MEMBER"})
def user_role(roles: Set[str]) -> str:
if "SUPERVISOR" in roles:
return "SUPERVISOR"
if "EXPERT" in roles:
return "EXPERT"
if "MEMBER" in roles:
return "MEMBER"
return "LEARNING_MENTOR"
def _create_course_session_dict(course_session_object, my_role, user_role):
return {
"id": str(course_session_object.id),
"session_title": course_session_object.title,
"course_id": str(course_session_object.course.id),
"course_title": course_session_object.course.title,
"course_slug": course_session_object.course.slug,
"region": course_session_object.region,
"generation": course_session_object.generation,
"my_role": my_role,
"user_role": user_role,
"is_uk": course_session_object.course.configuration.is_uk,
"is_vv": course_session_object.course.configuration.is_vv,
}
def _create_person_list_with_roles(user):
def create_user_dict(user_object):
return {
"user_id": user_object.id,
"first_name": user_object.first_name,
"last_name": user_object.last_name,
"email": user_object.email,
"avatar_url_small": user_object.avatar_url_small,
"avatar_url": user_object.avatar_url,
"course_sessions": [],
}
course_sessions = get_course_sessions_with_roles_for_user(user)
result_persons = {}
for cs in course_sessions:
if has_cs_role(cs.roles) and cs.course.configuration.is_uk:
course_session_users = CourseSessionUser.objects.filter(
course_session=cs.id
)
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["course_sessions"].append(
_create_course_session_dict(cs, my_role, csu.role)
)
result_persons[csu.user.id] = person_data
# add persons where request.user is mentor
for cs in course_sessions:
if "LEARNING_MENTOR" in cs.roles:
lm = LearningMentor.objects.filter(
mentor=user, course_session=cs.id
).first()
for participant in lm.participants.all():
course_session_entry = _create_course_session_dict(
cs,
"LEARNING_MENTOR",
"LEARNING_MENTEE",
)
if participant.user.id not in result_persons:
person_data = create_user_dict(participant.user)
person_data["course_sessions"] = [course_session_entry]
result_persons[participant.user.id] = person_data
else:
# user is already in result_persons
result_persons[participant.user.id]["course_sessions"].append(
course_session_entry
)
# add persons where request.user is mentee
mentor_relation_qs = LearningMentor.objects.filter(
participants__user=user
).prefetch_related("mentor", "course_session")
for mentor_relation in mentor_relation_qs:
cs = mentor_relation.course_session
course_session_entry = _create_course_session_dict(
cs,
"LEARNING_MENTEE",
"LEARNING_MENTOR",
)
if mentor_relation.mentor.id not in result_persons:
person_data = create_user_dict(mentor_relation.mentor)
person_data["course_sessions"] = [course_session_entry]
result_persons[mentor_relation.mentor.id] = person_data
else:
# user is already in result_persons
result_persons[mentor_relation.mentor.id]["course_sessions"].append(
course_session_entry
)
return result_persons.values()
def _persons_list_add_competence_metrics(persons):
course_session_ids = {cs["id"] for p in persons for cs in p["course_sessions"]}
competence_assignments = query_competence_course_session_assignments(
@ -285,7 +123,7 @@ def _persons_list_add_competence_metrics(persons):
@api_view(["GET"])
def get_dashboard_persons(request):
try:
persons = list(_create_person_list_with_roles(request.user))
persons = list(create_person_list_with_roles(request.user))
if request.GET.get("with_competence_metrics", "") == "true":
persons = _persons_list_add_competence_metrics(persons)
@ -307,45 +145,28 @@ def get_dashboard_due_dates(request):
course_sessions = get_course_sessions_with_roles_for_user(request.user)
course_session_ids = [cs.id for cs in course_sessions]
all_due_dates = DueDate.objects.filter(
course_session__id__in=course_session_ids
)
# filter only future due dates
due_dates = []
today = date.today()
for due_date in all_due_dates:
# due_dates.append(due_date)
if due_date.end:
if due_date.end.date() >= today:
due_dates.append(due_date)
elif due_date.start:
if due_date.start.date() >= today:
due_dates.append(due_date)
due_dates.sort(key=lambda x: x.start)
# find course session by id in `course_sessions`
# Fetch future due dates in a single query using Q objects for complex filtering
future_due_dates = DueDate.objects.filter(
Q(course_session_id__in=course_session_ids),
Q(end__gte=today) | Q(start__gte=today),
).select_related("course_session")
result_due_dates = []
for due_date in due_dates:
data = DueDateSerializer(due_date).data
course_session_map = {cs.id: cs for cs in course_sessions}
for due_date in sorted(future_due_dates, key=lambda x: x.start):
data = DueDateSerializer(due_date).data
cs = course_session_map.get(due_date.course_session_id)
cs = next(
course_session
for course_session in course_sessions
if course_session.id == due_date.course_session.id
)
if cs:
data["course_session"] = _create_course_session_dict(
data["course_session"] = create_course_session_dict(
cs, my_role=user_role(cs.roles), user_role=""
)
result_due_dates.append(data)
return Response(
status=200,
data=result_due_dates,
)
return Response(status=200, data=result_due_dates)
except PermissionDenied as e:
raise e
@ -595,6 +416,20 @@ def export_feedback_as_xsl(request):
return _make_excel_response(data, FEEDBACK_EXPORT_FILE_NAME)
@api_view(["POST"])
def export_persons_as_xsl(request):
requested_course_session_ids = request.data.get("courseSessionIds", [])
course_sessions_with_roles = _get_permitted_courses_sessions_for_user(
request.user, requested_course_session_ids
) # noqa
data = export_persons(
request.user,
[cswr.id for cswr in course_sessions_with_roles],
)
return _make_excel_response(data, PERSONS_EXPORT_FILENAME)
def _get_permitted_courses_sessions_for_user(
user: User, requested_coursesession_ids: List[str]
) -> List[CourseSessionWithRoles]:

View File

@ -0,0 +1,38 @@
import time
import structlog
from django.db import connection, reset_queries
logger = structlog.get_logger(__name__)
class QueryCountDebugMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not request.path.startswith("/api/") and not request.path.startswith(
"/server/"
):
return self.get_response(request)
reset_queries()
start_queries = len(connection.queries)
start_time = time.time()
response = self.get_response(request)
end_queries = len(connection.queries)
end_time = time.time()
total_queries = end_queries - start_queries
duration = end_time - start_time
logger.debug(
"query_count_middleware",
request_path=request.path,
queries=total_queries,
duration=duration,
)
return response

View File

@ -765,7 +765,7 @@ def create_or_update_course_session_assignment(
csa.submission_deadline.save()
csa.evaluation_deadline.start = timezone.make_aware(
start
) + timezone.timedelta(days=45)
) + timezone.timedelta(days=60)
csa.evaluation_deadline.end = None
csa.evaluation_deadline.save()
else:

View File

@ -78,32 +78,32 @@ def create_customer_xml(checkout_information: CheckoutInformation):
first_name=checkout_information.first_name,
company_name=(
checkout_information.organisation_detail_name
if checkout_information.invoice_address == "org"
if checkout_information.abacus_use_organisation_data()
else ""
),
street=(
checkout_information.organisation_street
if checkout_information.invoice_address == "org"
if checkout_information.abacus_use_organisation_data()
else checkout_information.street
),
house_number=(
checkout_information.organisation_street_number
if checkout_information.invoice_address == "org"
if checkout_information.abacus_use_organisation_data()
else checkout_information.street_number
),
zip_code=(
checkout_information.organisation_postal_code
if checkout_information.invoice_address == "org"
if checkout_information.abacus_use_organisation_data()
else checkout_information.postal_code
),
city=(
checkout_information.organisation_city
if checkout_information.invoice_address == "org"
if checkout_information.abacus_use_organisation_data()
else checkout_information.city
),
country=(
checkout_information.organisation_country_id
if checkout_information.invoice_address == "org"
if checkout_information.abacus_use_organisation_data()
else checkout_information.country_id
),
language=customer.language,

View File

@ -128,3 +128,15 @@ class CheckoutInformation(models.Model):
self.abacus_order_id = new_abacus_order_id
self.save()
return self
def abacus_address_type(self) -> str:
# always use priv for abacus and CembraPay
return (
self.INVOICE_ADDRESS_ORGANISATION
if self.invoice_address == self.INVOICE_ADDRESS_ORGANISATION
and not self.cembra_byjuno_invoice
else self.INVOICE_ADDRESS_PRIVATE
)
def abacus_use_organisation_data(self) -> bool:
return self.abacus_address_type() == self.INVOICE_ADDRESS_ORGANISATION

View File

@ -1,13 +1,11 @@
import hashlib
import hmac
import uuid
import requests
import structlog
from django.conf import settings
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.shop.const import VV_PRODUCT_NUMBER
from vbv_lernwelt.shop.datatrans.datatrans_api_client import DatatransApiClient
from vbv_lernwelt.shop.models import CheckoutState
@ -73,6 +71,7 @@ def is_signature_valid(
def init_datatrans_transaction(
user: User,
refno: str,
amount_chf_centimes: int,
redirect_url_success: str,
redirect_url_error: str,
@ -91,8 +90,8 @@ def init_datatrans_transaction(
"amount": amount_chf_centimes,
"currency": "CHF",
"language": user.language,
"refno": str(uuid.uuid4()),
"refno2": refno2,
"refno": str(refno),
"refno2": str(refno2),
"webhook": {"url": webhook_url},
"redirect": {
"successUrl": redirect_url_success,
@ -101,9 +100,8 @@ def init_datatrans_transaction(
},
}
# FIXME: test with working cembra byjuno invoice customer?
# if with_cembra_byjuno_invoice:
# payload["paymentMethods"] = ["INT"]
if with_cembra_byjuno_invoice:
payload["paymentMethods"] = ["INT"]
if datatrans_customer_data:
payload["customer"] = datatrans_customer_data
if datatrans_int_data:

View File

@ -163,6 +163,53 @@ class AbacusInvoiceTestCase(TestCase):
assert "<Street>Laupenstrasse</Street>" in customer_xml_content
assert "<Country>CH</Country>" in customer_xml_content
def test_create_customer_xml_byjuno_cembra_with_company_address(self):
_pat = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")
_pat.abacus_debitor_number = 60000011
_pat.save()
_ignore_checkout_information = CheckoutInformationFactory(
user=_pat, abacus_order_id=6_000_000_123
)
feuz = User.objects.get(username="andreas.feuz@eiger-versicherungen.ch")
feuz_checkout_info = CheckoutInformationFactory(
user=feuz,
transaction_id="24021508331287484",
first_name="Andreas",
last_name="Feuz",
street="Eggersmatt",
street_number="32",
postal_code="1719",
city="Zumholz",
country_id="CH",
invoice_address="org",
organisation_detail_name="VBV",
organisation_street="Laupenstrasse",
organisation_street_number="10",
organisation_postal_code="3000",
organisation_city="Bern",
organisation_country_id="CH",
cembra_byjuno_invoice=True,
)
feuz_checkout_info.created_at = datetime(2024, 2, 15, 8, 33, 12, 0)
customer_xml_filename, customer_xml_content = create_customer_xml(
checkout_information=feuz_checkout_info
)
print(customer_xml_content)
print(customer_xml_filename)
self.assertEqual("myVBV_debi_60000012.xml", customer_xml_filename)
assert "<CustomerNumber>60000012</CustomerNumber>" in customer_xml_content
assert (
"<Email>andreas.feuz@eiger-versicherungen.ch</Email>"
in customer_xml_content
)
assert "<AddressNumber>60000012</AddressNumber>" in customer_xml_content
assert "<Name>Feuz</Name>" in customer_xml_content
assert "<Text>VBV</Text>" not in customer_xml_content
assert "<Street>Eggersmatt</Street>" in customer_xml_content
def test_render_customer_xml(self):
customer_xml = render_customer_xml(
abacus_debitor_number=60000012,

View File

@ -71,9 +71,8 @@ class CheckoutAPITestCase(APITestCase):
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
f"https://pay.sandbox.datatrans.com/v1/start/1234567890",
response.json()["next_step_url"],
self.assertTrue(
response.json()["next_step_url"].endswith("v1/start/1234567890")
)
ci = CheckoutInformation.objects.first()
@ -154,9 +153,11 @@ class CheckoutAPITestCase(APITestCase):
)
self.assertEqual(
0,
1,
CheckoutInformation.objects.count(),
)
ci = CheckoutInformation.objects.first()
self.assertEqual(ci.state, CheckoutState.FAILED)
def test_checkout_already_paid(self):
# GIVEN
@ -217,9 +218,8 @@ class CheckoutAPITestCase(APITestCase):
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id_next}",
response.json()["next_step_url"],
self.assertTrue(
response.json()["next_step_url"].endswith(f"v1/start/{transaction_id_next}")
)
# check that we have two checkouts
@ -277,9 +277,8 @@ class CheckoutAPITestCase(APITestCase):
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id}",
response.json()["next_step_url"],
self.assertTrue(
response.json()["next_step_url"].endswith(f"v1/start/{transaction_id}")
)
@patch("vbv_lernwelt.shop.views.init_datatrans_transaction")
@ -310,7 +309,6 @@ class CheckoutAPITestCase(APITestCase):
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id}",
response.json()["next_step_url"],
self.assertTrue(
response.json()["next_step_url"].endswith(f"v1/start/{transaction_id}")
)

View File

@ -24,10 +24,8 @@ class DatatransServiceTest(TestCase):
@override_settings(DATATRANS_BASIC_AUTH_KEY="BASIC_AUTH_KEY")
@patch("vbv_lernwelt.shop.services.requests.post")
@patch("vbv_lernwelt.shop.services.uuid.uuid4")
def test_init_transaction_201(self, mock_uuid, mock_post):
def test_init_transaction_201(self, mock_post):
# GIVEN
mock_uuid.return_value = uuid.uuid4()
mock_post.return_value.status_code = 201
mock_post.return_value.json.return_value = {
"transactionId": 1234567890,
@ -38,6 +36,7 @@ class DatatransServiceTest(TestCase):
# WHEN
transaction_id = init_datatrans_transaction(
user=self.user,
refno="123321",
amount_chf_centimes=324_30,
redirect_url_success=f"{REDIRECT_URL}/success",
redirect_url_error=f"{REDIRECT_URL}/error",
@ -64,11 +63,12 @@ class DatatransServiceTest(TestCase):
with self.assertRaises(InitTransactionException):
init_datatrans_transaction(
user=self.user,
refno="123321",
amount_chf_centimes=324_30,
redirect_url_success=f"/success",
redirect_url_error=f"/error",
redirect_url_cancel=f"/cancel",
webhook_url=f"/webhook",
redirect_url_success="/success",
redirect_url_error="/error",
redirect_url_cancel="/cancel",
webhook_url="/webhook",
refno2="",
)
@ -80,7 +80,4 @@ class DatatransServiceTest(TestCase):
url = get_payment_url(transaction_id)
# THEN
self.assertEqual(
url,
f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id}",
)
self.assertTrue(url.endswith(f"v1/start/{transaction_id}"))

View File

@ -93,7 +93,7 @@ def checkout_vv(request):
sku = request.data["product"]
base_redirect_url = request.data["redirect_url"]
log.info(f"Checkout requested: sku", user_id=request.user.id, sku=sku)
log.info("Checkout requested: sku", user_id=request.user.id, sku=sku)
try:
product = Product.objects.get(sku=sku)
@ -124,6 +124,38 @@ def checkout_vv(request):
disable_save="fakeapi" in settings.DATATRANS_API_ENDPOINT
)
address_data = request.data["address"]
country_code = address_data.pop("country_code")
address_data["country_id"] = country_code
organisation_country_code = "CH"
if "organisation_country_code" in address_data:
organisation_country_code = address_data.pop("organisation_country_code")
address_data["organisation_country_id"] = organisation_country_code
if "birth_date" in address_data and address_data["birth_date"]:
address_data["birth_date"] = date.fromisoformat(address_data["birth_date"])
checkout_info = CheckoutInformation.objects.create(
user=request.user,
state=CheckoutState.ONGOING,
# product
product_sku=sku,
product_price=product.price,
product_name=product.name,
product_description=product.description,
email=email,
ip_address=ip_address,
cembra_byjuno_invoice=with_cembra_byjuno_invoice,
device_fingerprint_session_key=request.data.get(
"device_fingerprint_session_key", ""
),
# address
**request.data["address"],
)
checkout_info.set_increment_abacus_order_id()
refno2 = f"{request.user.abacus_debitor_number}_{VV_PRODUCT_NUMBER}"
try:
@ -138,10 +170,10 @@ def checkout_vv(request):
"street": f'{request.data["address"]["street"]} {request.data["address"]["street_number"]}',
"city": request.data["address"]["city"],
"zipCode": request.data["address"]["postal_code"],
"country": request.data["address"]["country_code"],
"country": request.data["address"]["country_id"],
"phone": request.data["address"]["phone_number"],
"email": email,
"birthDate": request.data["address"]["birth_date"],
"birthDate": str(request.data["address"]["birth_date"]),
"language": request.user.language,
"ipAddress": ip_address,
"type": "P",
@ -154,6 +186,7 @@ def checkout_vv(request):
}
transaction_id = init_datatrans_transaction(
user=request.user,
refno=str(checkout_info.abacus_order_id),
amount_chf_centimes=product.price,
redirect_url_success=checkout_success_url(
base_url=base_redirect_url, product_sku=sku
@ -170,6 +203,8 @@ def checkout_vv(request):
with_cembra_byjuno_invoice=with_cembra_byjuno_invoice,
)
except InitTransactionException as e:
checkout_info.state = CheckoutState.FAILED.value
checkout_info.save()
if not settings.DEBUG:
log.error("Transaction initiation failed", exc_info=True, error=str(e))
capture_exception(e)
@ -180,36 +215,8 @@ def checkout_vv(request):
),
)
address_data = request.data["address"]
country_code = address_data.pop("country_code")
address_data["country_id"] = country_code
organisation_country_code = "CH"
if "organisation_country_code" in address_data:
organisation_country_code = address_data.pop("organisation_country_code")
address_data["organisation_country_id"] = organisation_country_code
if "birth_date" in address_data and address_data["birth_date"]:
address_data["birth_date"] = date.fromisoformat(address_data["birth_date"])
checkout_info = CheckoutInformation.objects.create(
user=request.user,
state=CheckoutState.ONGOING,
transaction_id=transaction_id,
# product
product_sku=sku,
product_price=product.price,
product_name=product.name,
product_description=product.description,
email=email,
ip_address=ip_address,
cembra_byjuno_invoice=with_cembra_byjuno_invoice,
device_fingerprint_session_key=request.data.get(
"device_fingerprint_session_key", ""
),
# address
**request.data["address"],
)
checkout_info.transaction_id = transaction_id
checkout_info.save()
return next_step_response(url=get_payment_url(transaction_id))