Merged in feature/cembra (pull request #345)
Feature/cembra Zahlung auf Rechnung
This commit is contained in:
commit
32274108db
|
|
@ -15,6 +15,7 @@
|
||||||
"@urql/exchange-graphcache": "^6.3.2",
|
"@urql/exchange-graphcache": "^6.3.2",
|
||||||
"@urql/introspection": "^1.0.2",
|
"@urql/introspection": "^1.0.2",
|
||||||
"@urql/vue": "^1.1.2",
|
"@urql/vue": "^1.1.2",
|
||||||
|
"@vuepic/vue-datepicker": "^8.8.1",
|
||||||
"@vueuse/core": "^10.9.0",
|
"@vueuse/core": "^10.9.0",
|
||||||
"@vueuse/router": "^10.9.0",
|
"@vueuse/router": "^10.9.0",
|
||||||
"cypress": "^12.14.0",
|
"cypress": "^12.14.0",
|
||||||
|
|
@ -8212,6 +8213,26 @@
|
||||||
"@vue/language-core": "1.8.1"
|
"@vue/language-core": "1.8.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vuepic/vue-datepicker": {
|
||||||
|
"version": "8.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-8.8.1.tgz",
|
||||||
|
"integrity": "sha512-8ehfUz1m69Vuc16Pm4ukgb3Mg1VT14x4EsG1ag4O/qbSNRWztTo+pUV4JnFt0FGLl5gGb6NXlxIvR7EjLgD7Gg==",
|
||||||
|
"dependencies": {
|
||||||
|
"date-fns": "^3.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": ">=3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vuepic/vue-datepicker/node_modules/date-fns": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vueuse/core": {
|
"node_modules/@vueuse/core": {
|
||||||
"version": "10.9.0",
|
"version": "10.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.9.0.tgz",
|
||||||
|
|
@ -26717,6 +26738,21 @@
|
||||||
"@vue/language-core": "1.8.1"
|
"@vue/language-core": "1.8.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@vuepic/vue-datepicker": {
|
||||||
|
"version": "8.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-8.8.1.tgz",
|
||||||
|
"integrity": "sha512-8ehfUz1m69Vuc16Pm4ukgb3Mg1VT14x4EsG1ag4O/qbSNRWztTo+pUV4JnFt0FGLl5gGb6NXlxIvR7EjLgD7Gg==",
|
||||||
|
"requires": {
|
||||||
|
"date-fns": "^3.6.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"date-fns": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@vueuse/core": {
|
"@vueuse/core": {
|
||||||
"version": "10.9.0",
|
"version": "10.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.9.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
"@urql/exchange-graphcache": "^6.3.2",
|
"@urql/exchange-graphcache": "^6.3.2",
|
||||||
"@urql/introspection": "^1.0.2",
|
"@urql/introspection": "^1.0.2",
|
||||||
"@urql/vue": "^1.1.2",
|
"@urql/vue": "^1.1.2",
|
||||||
|
"@vuepic/vue-datepicker": "^8.8.1",
|
||||||
"@vueuse/core": "^10.9.0",
|
"@vueuse/core": "^10.9.0",
|
||||||
"@vueuse/router": "^10.9.0",
|
"@vueuse/router": "^10.9.0",
|
||||||
"cypress": "^12.14.0",
|
"cypress": "^12.14.0",
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,9 @@ const user = useUserStore();
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
{{ $t("start.vvDescription") }}
|
{{ $t("start.vvDescription") }}
|
||||||
</p>
|
</p>
|
||||||
<a href="/start/vv" class="btn-primary">{{ $t("a.Mehr erfahren") }}</a>
|
<a href="/start/vv" class="btn-primary" data-cy="start-vv">
|
||||||
|
{{ $t("a.Mehr erfahren") }}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -39,6 +41,7 @@ const user = useUserStore();
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'accountProfile', params: { courseType: 'uk' } }"
|
:to="{ name: 'accountProfile', params: { courseType: 'uk' } }"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
|
data-cy="start-uk"
|
||||||
>
|
>
|
||||||
{{ $t("a.Jetzt mit Lehrgang starten") }}
|
{{ $t("a.Jetzt mit Lehrgang starten") }}
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<template>
|
||||||
|
<div ref="scriptContainer"></div>
|
||||||
|
<noscript>
|
||||||
|
<iframe
|
||||||
|
:src="iframeSrc"
|
||||||
|
style="width: 100px; height: 100px; border: 0; position: absolute; top: -5000px"
|
||||||
|
></iframe>
|
||||||
|
</noscript>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { ref, computed, onMounted } from "vue";
|
||||||
|
import { getLocalSessionKey } from "@/statistics";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// code needed for Datatrans/Cembra device fingerprinting
|
||||||
|
setup() {
|
||||||
|
const scriptContainer = ref(null);
|
||||||
|
const sessionId = getLocalSessionKey();
|
||||||
|
|
||||||
|
const iframeSrc = computed(() => {
|
||||||
|
return `https://h.online-metrix.net/tags?org_id=lq866c5i&session_id=${sessionId}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertScript = () => {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.type = "text/javascript";
|
||||||
|
script.src = `https://h.online-metrix.net/fp/tags.js?org_id=lq866c5i&session_id=${sessionId}`;
|
||||||
|
if (scriptContainer.value) {
|
||||||
|
scriptContainer.value.appendChild(script);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
insertScript();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
scriptContainer,
|
||||||
|
iframeSrc,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useEntities } from "@/services/entities";
|
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";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: {
|
modelValue: {
|
||||||
|
|
@ -11,12 +15,29 @@ const props = defineProps<{
|
||||||
postal_code: string;
|
postal_code: string;
|
||||||
city: string;
|
city: string;
|
||||||
country_code: string;
|
country_code: string;
|
||||||
|
payment_method: string;
|
||||||
|
|
||||||
|
phone_number: string;
|
||||||
|
birth_date: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
const { countries } = useEntities();
|
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({
|
const address = computed({
|
||||||
get() {
|
get() {
|
||||||
|
|
@ -163,5 +184,90 @@ const address = computed({
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-span-full">
|
||||||
|
<label
|
||||||
|
for="paymentMethod"
|
||||||
|
class="block text-sm font-medium leading-6 text-gray-900"
|
||||||
|
>
|
||||||
|
{{ $t("a.Zahlungsart") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<select
|
||||||
|
id="paymentMethod"
|
||||||
|
v-model="address.payment_method"
|
||||||
|
required
|
||||||
|
name="paymentMethod"
|
||||||
|
class="block w-full border-0 py-1.5 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
|
||||||
|
>
|
||||||
|
<option v-for="pm in paymentMethods" :key="pm.value" :value="pm.value">
|
||||||
|
{{ pm.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="address.payment_method === 'cembra_byjuno'" class="col-span-full">
|
||||||
|
<p class="mt-4">
|
||||||
|
{{ $t("shop.paymentCembraByjunoMessage") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="address.payment_method === 'cembra_byjuno'" class="col-span-full">
|
||||||
|
<label for="phone" class="block text-sm font-medium leading-6 text-gray-900">
|
||||||
|
{{ $t("a.Telefonnummer") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="phone"
|
||||||
|
v-model="address.phone_number"
|
||||||
|
type="text"
|
||||||
|
name="phone"
|
||||||
|
autocomplete="phone-number"
|
||||||
|
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 sm:text-sm sm:leading-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="address.payment_method === 'cembra_byjuno'" class="col-span-full">
|
||||||
|
<label for="birth-date" class="block text-sm font-medium leading-6 text-gray-900">
|
||||||
|
{{ $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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { i18nextInit } from "@/i18nextWrapper";
|
import { i18nextInit } from "@/i18nextWrapper";
|
||||||
|
import { generateLocalSessionKey } from "@/statistics";
|
||||||
import * as Sentry from "@sentry/vue";
|
import * as Sentry from "@sentry/vue";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
import I18NextVue from "i18next-vue";
|
import I18NextVue from "i18next-vue";
|
||||||
|
|
@ -24,6 +25,8 @@ if (appEnv.startsWith("prod")) {
|
||||||
log.setLevel("trace");
|
log.setLevel("trace");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generateLocalSessionKey();
|
||||||
|
|
||||||
const commit = "VBV_VERSION_BUILD_NUMBER_VBV";
|
const commit = "VBV_VERSION_BUILD_NUMBER_VBV";
|
||||||
log.warn(`application started appEnv=${appEnv}, build=${commit}`);
|
log.warn(`application started appEnv=${appEnv}, build=${commit}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ const userStore = useUserStore();
|
||||||
<template>
|
<template>
|
||||||
<WizardPage :step="0.5">
|
<WizardPage :step="0.5">
|
||||||
<template #content>
|
<template #content>
|
||||||
<h2 class="my-10">{{ $t("a.Konto erstellen") }}</h2>
|
<h2 class="my-10" data-cy="account-confirm-title">
|
||||||
|
{{ $t("a.Konto erstellen") }}
|
||||||
|
</h2>
|
||||||
<InfoBox color="text-green-500" icon="it-icon-check">
|
<InfoBox color="text-green-500" icon="it-icon-check">
|
||||||
<template #content>
|
<template #content>
|
||||||
<p class="text-lg font-bold">
|
<p class="text-lg font-bold">
|
||||||
|
|
@ -27,7 +29,11 @@ const userStore = useUserStore();
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<router-link :to="{ name: 'accountProfile' }" class="btn-blue flex items-center">
|
<router-link
|
||||||
|
:to="{ name: 'accountProfile' }"
|
||||||
|
class="btn-blue flex items-center"
|
||||||
|
data-cy="continue-button"
|
||||||
|
>
|
||||||
{{ $t("general.next") }}
|
{{ $t("general.next") }}
|
||||||
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
|
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,9 @@ const nextRoute = computed(() => {
|
||||||
<template>
|
<template>
|
||||||
<WizardPage :step="1">
|
<WizardPage :step="1">
|
||||||
<template #content>
|
<template #content>
|
||||||
<h2 class="my-10">{{ $t("a.Profil ergänzen") }}</h2>
|
<h2 class="my-10" data-cy="account-profile-title">
|
||||||
|
{{ $t("a.Profil ergänzen") }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<h3 class="mb-3">{{ $t("a.Gesellschaft") }}</h3>
|
<h3 class="mb-3">{{ $t("a.Gesellschaft") }}</h3>
|
||||||
|
|
||||||
|
|
@ -120,6 +122,7 @@ const nextRoute = computed(() => {
|
||||||
:disabled="!validOrganisation"
|
:disabled="!validOrganisation"
|
||||||
class="btn-blue flex items-center"
|
class="btn-blue flex items-center"
|
||||||
role="link"
|
role="link"
|
||||||
|
data-cy="continue-button"
|
||||||
@click="navigate"
|
@click="navigate"
|
||||||
>
|
>
|
||||||
{{ $t("general.next") }}
|
{{ $t("general.next") }}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import WizardPage from "@/components/onboarding/WizardPage.vue";
|
import WizardPage from "@/components/onboarding/WizardPage.vue";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { type User, useUserStore } from "@/stores/user";
|
import { type User, useUserStore } from "@/stores/user";
|
||||||
import PersonalAddress from "@/components/onboarding/PersonalAddress.vue";
|
import PersonalAddress from "@/components/onboarding/PersonalAddress.vue";
|
||||||
import OrganisationAddress from "@/components/onboarding/OrganisationAddress.vue";
|
import OrganisationAddress from "@/components/onboarding/OrganisationAddress.vue";
|
||||||
|
|
@ -9,6 +9,10 @@ import { useEntities } from "@/services/entities";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { useTranslation } from "i18next-vue";
|
import { useTranslation } from "i18next-vue";
|
||||||
import { getVVCourseName } from "./composables";
|
import { getVVCourseName } from "./composables";
|
||||||
|
import DatatransCembraDeviceFingerprint from "@/components/onboarding/DatatransCembraDeviceFingerprint.vue";
|
||||||
|
import { getLocalSessionKey } from "@/statistics";
|
||||||
|
import log from "loglevel";
|
||||||
|
import { normalizeSwissPhoneNumber, validatePhoneNumber } from "@/utils/phone";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
courseType: {
|
courseType: {
|
||||||
|
|
@ -50,6 +54,12 @@ const address = ref({
|
||||||
postal_code: user.postal_code,
|
postal_code: user.postal_code,
|
||||||
city: user.city,
|
city: user.city,
|
||||||
country_code: user.country?.country_code ?? "CH",
|
country_code: user.country?.country_code ?? "CH",
|
||||||
|
|
||||||
|
payment_method: "credit_card",
|
||||||
|
|
||||||
|
phone_number: user.phone_number,
|
||||||
|
birth_date: user.birth_date,
|
||||||
|
|
||||||
organisation_detail_name: user.organisation_detail_name,
|
organisation_detail_name: user.organisation_detail_name,
|
||||||
organisation_street: user.organisation_street,
|
organisation_street: user.organisation_street,
|
||||||
organisation_street_number: user.organisation_street_number,
|
organisation_street_number: user.organisation_street_number,
|
||||||
|
|
@ -59,13 +69,22 @@ const address = ref({
|
||||||
invoice_address: user.invoice_address ?? "prv",
|
invoice_address: user.invoice_address ?? "prv",
|
||||||
});
|
});
|
||||||
|
|
||||||
const useCompanyAddress = ref(user.invoice_address === "org");
|
const withCompanyAddress = ref(user.invoice_address === "org");
|
||||||
|
|
||||||
const setUseCompanyAddress = (value: boolean) => {
|
const setWithCompanyAddress = (value: boolean) => {
|
||||||
useCompanyAddress.value = value;
|
withCompanyAddress.value = value;
|
||||||
address.value.invoice_address = value ? "org" : "prv";
|
address.value.invoice_address = value ? "org" : "prv";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => address.value.payment_method,
|
||||||
|
(newValue) => {
|
||||||
|
if (newValue === "cembra_byjuno") {
|
||||||
|
setWithCompanyAddress(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
type FormErrors = {
|
type FormErrors = {
|
||||||
personal: string[];
|
personal: string[];
|
||||||
company: string[];
|
company: string[];
|
||||||
|
|
@ -110,7 +129,25 @@ function validateAddress() {
|
||||||
formErrors.value.personal.push(t("a.Land"));
|
formErrors.value.personal.push(t("a.Land"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (useCompanyAddress.value) {
|
if (address.value.phone_number) {
|
||||||
|
const normalizedPhoneNumber = normalizeSwissPhoneNumber(address.value.phone_number);
|
||||||
|
log.debug("normalizedPhoneNumber", normalizedPhoneNumber);
|
||||||
|
if (!validatePhoneNumber(normalizedPhoneNumber)) {
|
||||||
|
formErrors.value.personal.push(t("a.Telefonnummer hat das falsche Format"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (address.value.payment_method === "cembra_byjuno") {
|
||||||
|
if (!address.value.phone_number) {
|
||||||
|
formErrors.value.personal.push(t("a.Telefonnummer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address.value.birth_date) {
|
||||||
|
formErrors.value.personal.push(t("a.Geburtsdatum"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (withCompanyAddress.value) {
|
||||||
if (!address.value.organisation_detail_name) {
|
if (!address.value.organisation_detail_name) {
|
||||||
formErrors.value.company.push(t("a.Name"));
|
formErrors.value.company.push(t("a.Name"));
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +175,8 @@ function validateAddress() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveAddress() {
|
async function saveAddress() {
|
||||||
const { country_code, organisation_country_code, ...profileData } = address.value;
|
const { country_code, organisation_country_code, phone_number, ...profileData } =
|
||||||
|
address.value;
|
||||||
const typedProfileData: Partial<User> = { ...profileData };
|
const typedProfileData: Partial<User> = { ...profileData };
|
||||||
|
|
||||||
typedProfileData.country = countries.value.find(
|
typedProfileData.country = countries.value.find(
|
||||||
|
|
@ -148,6 +186,10 @@ async function saveAddress() {
|
||||||
(c) => c.country_code === organisation_country_code
|
(c) => c.country_code === organisation_country_code
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (phone_number) {
|
||||||
|
typedProfileData.phone_number = normalizeSwissPhoneNumber(phone_number);
|
||||||
|
}
|
||||||
|
|
||||||
await user.updateUserProfile(typedProfileData);
|
await user.updateUserProfile(typedProfileData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,10 +208,20 @@ const executePayment = async () => {
|
||||||
// anyway, so it seems fine to do it here.
|
// anyway, so it seems fine to do it here.
|
||||||
const fullHost = `${window.location.protocol}//${window.location.host}`;
|
const fullHost = `${window.location.protocol}//${window.location.host}`;
|
||||||
|
|
||||||
|
if (address.value.phone_number) {
|
||||||
|
address.value.phone_number = normalizeSwissPhoneNumber(address.value.phone_number);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addressData = Object.assign({}, address.value);
|
||||||
|
// @ts-ignore
|
||||||
|
delete addressData.payment_method;
|
||||||
|
|
||||||
itPost("/api/shop/vv/checkout/", {
|
itPost("/api/shop/vv/checkout/", {
|
||||||
redirect_url: fullHost,
|
redirect_url: fullHost,
|
||||||
address: address.value,
|
address: addressData,
|
||||||
product: props.courseType,
|
product: props.courseType,
|
||||||
|
with_cembra_byjuno_invoice: address.value.payment_method === "cembra_byjuno",
|
||||||
|
device_fingerprint_session_key: getLocalSessionKey(),
|
||||||
}).then((res: any) => {
|
}).then((res: any) => {
|
||||||
console.log("Going to next page", res.next_step_url);
|
console.log("Going to next page", res.next_step_url);
|
||||||
window.location.href = res.next_step_url;
|
window.location.href = res.next_step_url;
|
||||||
|
|
@ -180,7 +232,12 @@ const executePayment = async () => {
|
||||||
<template>
|
<template>
|
||||||
<WizardPage :step="2">
|
<WizardPage :step="2">
|
||||||
<template #content>
|
<template #content>
|
||||||
<h2 class="my-10">{{ $t("a.Lehrgang kaufen") }}</h2>
|
<DatatransCembraDeviceFingerprint
|
||||||
|
v-if="address.payment_method === 'cembra_byjuno'"
|
||||||
|
/>
|
||||||
|
<h2 class="my-10" data-cy="account-checkout-title">
|
||||||
|
{{ $t("a.Lehrgang kaufen") }}
|
||||||
|
</h2>
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
<i18next
|
<i18next
|
||||||
:translation="$t('a.Der Preis für den Lehrgang {course} beträgt {price}.')"
|
:translation="$t('a.Der Preis für den Lehrgang {course} beträgt {price}.')"
|
||||||
|
|
@ -196,9 +253,6 @@ const executePayment = async () => {
|
||||||
$t("a.Mit dem Kauf erhältst du Zugang auf den gesamten Kurs (inkl. Prüfung).")
|
$t("a.Mit dem Kauf erhältst du Zugang auf den gesamten Kurs (inkl. Prüfung).")
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
|
||||||
{{ $t("a.Hier kannst du ausschliesslich mit einer Kreditkarte bezahlen.") }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-if="paymentError" class="text-bold mt-12 text-lg text-red-700">
|
<p v-if="paymentError" class="text-bold mt-12 text-lg text-red-700">
|
||||||
{{
|
{{
|
||||||
|
|
@ -231,50 +285,55 @@ const executePayment = async () => {
|
||||||
{{ formErrors.personal.join(", ") }}
|
{{ formErrors.personal.join(", ") }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<section v-if="address.payment_method !== 'cembra_byjuno'">
|
||||||
v-if="!useCompanyAddress"
|
<div class="mt-4">
|
||||||
class="underline"
|
<button
|
||||||
@click="setUseCompanyAddress(true)"
|
v-if="!withCompanyAddress"
|
||||||
>
|
class="underline"
|
||||||
<template v-if="userOrganisationName">
|
data-cy="add-company-address"
|
||||||
{{
|
@click="setWithCompanyAddress(true)"
|
||||||
$t("a.Rechnungsadresse von {organisation} hinzufügen", {
|
>
|
||||||
organisation: userOrganisationName,
|
<template v-if="userOrganisationName">
|
||||||
})
|
|
||||||
}}
|
|
||||||
</template>
|
|
||||||
<template v-else>{{ $t("a.Rechnungsadresse hinzufügen") }}</template>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<transition
|
|
||||||
enter-active-class="transition ease-out duration-100"
|
|
||||||
enter-from-class="transform opacity-0 scale-y-95"
|
|
||||||
enter-to-class="transform opacity-100 scale-y-100"
|
|
||||||
leave-active-class="transition ease-in duration-75"
|
|
||||||
leave-from-class="transform opacity-100 scale-y-100"
|
|
||||||
leave-to-class="transform opacity-0 scale-y-95"
|
|
||||||
>
|
|
||||||
<div v-if="useCompanyAddress">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 v-if="userOrganisationName">
|
|
||||||
{{
|
{{
|
||||||
$t("a.Rechnungsadresse von {organisation}", {
|
$t("a.Rechnungsadresse von {organisation} hinzufügen", {
|
||||||
organisation: userOrganisationName,
|
organisation: userOrganisationName,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</h3>
|
</template>
|
||||||
<h3 v-else>{{ $t("a.Rechnungsadresse") }}</h3>
|
<template v-else>{{ $t("a.Rechnungsadresse hinzufügen") }}</template>
|
||||||
<button class="underline" @click="setUseCompanyAddress(false)">
|
</button>
|
||||||
{{ $t("a.Entfernen") }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<OrganisationAddress v-model="address" />
|
|
||||||
<p v-if="formErrors.company.length" class="text-red-700">
|
|
||||||
{{ $t("a.Bitte folgende Felder ausfüllen") }}:
|
|
||||||
{{ formErrors.company.join(", ") }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-100"
|
||||||
|
enter-from-class="transform opacity-0 scale-y-95"
|
||||||
|
enter-to-class="transform opacity-100 scale-y-100"
|
||||||
|
leave-active-class="transition ease-in duration-75"
|
||||||
|
leave-from-class="transform opacity-100 scale-y-100"
|
||||||
|
leave-to-class="transform opacity-0 scale-y-95"
|
||||||
|
>
|
||||||
|
<div v-if="withCompanyAddress">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 v-if="userOrganisationName">
|
||||||
|
{{
|
||||||
|
$t("a.Rechnungsadresse von {organisation}", {
|
||||||
|
organisation: userOrganisationName,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<h3 v-else>{{ $t("a.Rechnungsadresse") }}</h3>
|
||||||
|
<button class="underline" @click="setWithCompanyAddress(false)">
|
||||||
|
{{ $t("a.Entfernen") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<OrganisationAddress v-model="address" />
|
||||||
|
<p v-if="formErrors.company.length" class="text-red-700">
|
||||||
|
{{ $t("a.Bitte folgende Felder ausfüllen") }}:
|
||||||
|
{{ formErrors.company.join(", ") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
|
|
@ -285,8 +344,17 @@ const executePayment = async () => {
|
||||||
<it-icon-arrow-left class="it-icon mr-2 h-6 w-6" />
|
<it-icon-arrow-left class="it-icon mr-2 h-6 w-6" />
|
||||||
{{ $t("general.back") }}
|
{{ $t("general.back") }}
|
||||||
</router-link>
|
</router-link>
|
||||||
<button class="btn-blue flex items-center" @click="executePayment">
|
<button
|
||||||
{{ $t("a.Mit Kreditkarte bezahlen") }}
|
class="btn-blue flex items-center"
|
||||||
|
data-cy="continue-pay"
|
||||||
|
@click="executePayment"
|
||||||
|
>
|
||||||
|
<template v-if="address.payment_method === 'cembra_byjuno'">
|
||||||
|
{{ $t("a.Mit Rechnung bezahlen") }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ $t("a.Mit Kreditkarte bezahlen") }}
|
||||||
|
</template>
|
||||||
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
|
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ const user = useUserStore();
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-grow flex-col items-center gap-y-8 p-16">
|
<div class="flex flex-grow flex-col items-center gap-y-8 p-16">
|
||||||
<h1 class="my-10">{{ $t("a.Gratuliere!") }}</h1>
|
<h1 class="my-10" data-cy="checkout-success-title">{{ $t("a.Gratuliere!") }}</h1>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="472"
|
width="472"
|
||||||
|
|
@ -43,7 +43,7 @@ const user = useUserStore();
|
||||||
</template>
|
</template>
|
||||||
</i18next>
|
</i18next>
|
||||||
</p>
|
</p>
|
||||||
<router-link to="/" class="btn-blue flex items-center">
|
<router-link to="/" class="btn-blue flex items-center" data-cy="start-vv-button">
|
||||||
{{ $t("a.Jetzt mit Lehrgang starten") }}
|
{{ $t("a.Jetzt mit Lehrgang starten") }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
let statisticsLocalSessionKey = "";
|
||||||
|
let statisticsLocalSessionRef = "";
|
||||||
|
|
||||||
|
export function uuidv4() {
|
||||||
|
// copied from https://stackoverflow.com/a/2117523/669561
|
||||||
|
// @ts-ignore
|
||||||
|
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
|
||||||
|
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalSessionKey() {
|
||||||
|
return (
|
||||||
|
window.sessionStorage.getItem("statisticsLocalSessionKey") ||
|
||||||
|
statisticsLocalSessionKey ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateLocalSessionKey() {
|
||||||
|
let localSessionKey =
|
||||||
|
window.sessionStorage.getItem("statisticsLocalSessionKey") ||
|
||||||
|
statisticsLocalSessionKey;
|
||||||
|
if (!localSessionKey) {
|
||||||
|
localSessionKey = uuidv4();
|
||||||
|
}
|
||||||
|
statisticsLocalSessionKey = localSessionKey;
|
||||||
|
window.sessionStorage.setItem("statisticsLocalSessionKey", localSessionKey);
|
||||||
|
|
||||||
|
return localSessionKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalSessionRef() {
|
||||||
|
return (
|
||||||
|
window.sessionStorage.getItem("statisticsLocalSessionRef") ||
|
||||||
|
statisticsLocalSessionRef ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setLocalSessionRef(sessionRef: string) {
|
||||||
|
if (sessionRef) {
|
||||||
|
statisticsLocalSessionRef = sessionRef;
|
||||||
|
window.sessionStorage.setItem("statisticsLocalSessionRef", sessionRef);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,8 @@ export interface User {
|
||||||
postal_code: string;
|
postal_code: string;
|
||||||
city: string;
|
city: string;
|
||||||
country: Country | null;
|
country: Country | null;
|
||||||
|
birth_date: string;
|
||||||
|
phone_number: string;
|
||||||
organisation_detail_name: string;
|
organisation_detail_name: string;
|
||||||
organisation_street: string;
|
organisation_street: string;
|
||||||
organisation_street_number: string;
|
organisation_street_number: string;
|
||||||
|
|
@ -79,6 +81,8 @@ const initialUserState: User = {
|
||||||
postal_code: "",
|
postal_code: "",
|
||||||
city: "",
|
city: "",
|
||||||
country: null,
|
country: null,
|
||||||
|
birth_date: "",
|
||||||
|
phone_number: "",
|
||||||
organisation_detail_name: "",
|
organisation_detail_name: "",
|
||||||
organisation_street: "",
|
organisation_street: "",
|
||||||
organisation_street_number: "",
|
organisation_street_number: "",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { normalizeSwissPhoneNumber, validatePhoneNumber } from "../phone";
|
||||||
|
|
||||||
|
describe("normalizeSwissPhoneNumber", () => {
|
||||||
|
it("should normalize a Swiss phone number", () => {
|
||||||
|
expect(normalizeSwissPhoneNumber("079 123 45 67")).toBe("+41791234567");
|
||||||
|
expect(normalizeSwissPhoneNumber("00 41 79 123 45 67")).toBe("+41791234567");
|
||||||
|
expect(normalizeSwissPhoneNumber("+41 (0)79 201 85 86")).toBe("+41792018586");
|
||||||
|
expect(normalizeSwissPhoneNumber("+41 79 201 85 86")).toBe("+41792018586");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should normalize remove spaces and special chars from foreign numbers", () => {
|
||||||
|
expect(normalizeSwissPhoneNumber("+49 30 12345678")).toBe("+493012345678");
|
||||||
|
expect(normalizeSwissPhoneNumber("+49-30-12345678")).toBe("+493012345678");
|
||||||
|
|
||||||
|
expect(normalizeSwissPhoneNumber("+33 1 23 45 67 89")).toBe("+33123456789");
|
||||||
|
expect(normalizeSwissPhoneNumber("+33-1-23-45-67-89")).toBe("+33123456789");
|
||||||
|
|
||||||
|
expect(normalizeSwissPhoneNumber("+39 06 12345678")).toBe("+390612345678");
|
||||||
|
expect(normalizeSwissPhoneNumber("+43 1 2345678")).toBe("+4312345678");
|
||||||
|
expect(normalizeSwissPhoneNumber("+423 234 5678")).toBe("+4232345678");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validatePhoneNumber", () => {
|
||||||
|
it("should validate a Swiss phone number", () => {
|
||||||
|
expect(validatePhoneNumber("079 123 45 67")).toBe(true);
|
||||||
|
expect(validatePhoneNumber("00 41 79 123 45 67")).toBe(true);
|
||||||
|
expect(validatePhoneNumber("+41 (0)79 201 85 86")).toBe(true);
|
||||||
|
expect(validatePhoneNumber("+41 79 201 85 86")).toBe(true);
|
||||||
|
expect(validatePhoneNumber("026 418 01 31")).toBe(true);
|
||||||
|
|
||||||
|
expect(validatePhoneNumber("+41 79 201 85 86 8")).toBe(false);
|
||||||
|
expect(validatePhoneNumber("079 201 85 8")).toBe(false);
|
||||||
|
expect(validatePhoneNumber("aaa aaa aaa aaa")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate a foreign phone number", () => {
|
||||||
|
expect(validatePhoneNumber("+49 30 12345678")).toBe(true);
|
||||||
|
expect(validatePhoneNumber("+49 30 1234567")).toBe(false);
|
||||||
|
expect(validatePhoneNumber("+49 30 123456789")).toBe(false);
|
||||||
|
|
||||||
|
expect(validatePhoneNumber("+33 1 23 45 67 89")).toBe(true);
|
||||||
|
expect(validatePhoneNumber("+33 1 23 45 67 89 9")).toBe(false);
|
||||||
|
|
||||||
|
expect(validatePhoneNumber("+39 06 12345678")).toBe(true);
|
||||||
|
expect(validatePhoneNumber("+39 06 1234567")).toBe(false);
|
||||||
|
|
||||||
|
expect(validatePhoneNumber("+43 1 2345678")).toBe(true);
|
||||||
|
expect(validatePhoneNumber("+43 1 23456789")).toBe(false);
|
||||||
|
|
||||||
|
expect(validatePhoneNumber("+423 235 09 09")).toBe(true);
|
||||||
|
expect(validatePhoneNumber("+423 235 09 09 8")).toBe(false);
|
||||||
|
|
||||||
|
expect(validatePhoneNumber("+354 123 4567")).toBe(true);
|
||||||
|
expect(validatePhoneNumber("+55 12 34567 8901")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
export function normalizeSwissPhoneNumber(input: string) {
|
||||||
|
return input
|
||||||
|
.replace(/\s+/g, "")
|
||||||
|
.replace("(0)", "")
|
||||||
|
.replaceAll("-", "")
|
||||||
|
.replaceAll("/", "")
|
||||||
|
.replaceAll("(", "")
|
||||||
|
.replaceAll(")", "")
|
||||||
|
.replace(/^0041/, "+41")
|
||||||
|
.replace(/^\+410/, "+41")
|
||||||
|
.replace(/^0/, "+41");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validatePhoneNumber(input: string) {
|
||||||
|
const normalized = normalizeSwissPhoneNumber(input);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!normalized.startsWith("+") ||
|
||||||
|
isNaN(Number(normalized.slice(1))) ||
|
||||||
|
normalized[1] === "0"
|
||||||
|
) {
|
||||||
|
// phone number can only start with a + and must be followed by numbers
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (["+41", "+43", "+49", "+39", "+33", "+42"].includes(normalized.slice(0, 3))) {
|
||||||
|
if (
|
||||||
|
// Swiss and French phone numbers
|
||||||
|
(normalized.startsWith("+41") || normalized.startsWith("+33")) &&
|
||||||
|
normalized.length === 12
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
} else if (
|
||||||
|
// German and Italian phone numbers
|
||||||
|
(normalized.startsWith("+49") || normalized.startsWith("+39")) &&
|
||||||
|
normalized.length === 13
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
} else if (
|
||||||
|
// Austrian and Liechtenstein phone numbers
|
||||||
|
(normalized.startsWith("+43") || normalized.startsWith("+423")) &&
|
||||||
|
normalized.length === 11
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// every other country
|
||||||
|
if (normalized.length >= 10 || normalized.length <= 13) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displaySwissPhoneNumber(input: string) {
|
||||||
|
if (input && input.length === 12 && input.startsWith("+41")) {
|
||||||
|
input = input.replace("+41", "0");
|
||||||
|
let result = "";
|
||||||
|
result += input.substring(0, 3) + " ";
|
||||||
|
result += input.substring(3, 6) + " ";
|
||||||
|
result += input.substring(6, 8) + " ";
|
||||||
|
result += input.substring(8);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
// ids for cypress test data
|
// ids for cypress test data
|
||||||
export const ADMIN_USER_ID = "872efd96-3bd7-4a1e-a239-2d72cad9f604";
|
export const ADMIN_USER_ID = "872efd96-3bd7-4a1e-a239-2d72cad9f604"
|
||||||
export const TEST_TRAINER1_USER_ID = "b9e71f59-c44f-4290-b93a-9b3151e9a2fc";
|
export const TEST_SUPERVISOR1_USER_ID = "a9a8b741-f115-4521-af2d-7dfef673b8c5"
|
||||||
export const TEST_STUDENT1_USER_ID = "65c73ad0-6d53-43a9-a4a4-64143f27b03a";
|
export const TEST_TRAINER1_USER_ID = "b9e71f59-c44f-4290-b93a-9b3151e9a2fc"
|
||||||
export const TEST_STUDENT2_USER_ID = "19c40d94-15cc-4198-aaad-ef707c4b0900";
|
export const TEST_TRAINER2_USER_ID = "299941ae-1e4b-4f45-8180-876c3ad340b4"
|
||||||
|
export const TEST_STUDENT1_USER_ID = "65c73ad0-6d53-43a9-a4a4-64143f27b03a"
|
||||||
|
export const TEST_STUDENT2_USER_ID = "19c40d94-15cc-4198-aaad-ef707c4b0900"
|
||||||
|
export const TEST_STUDENT3_USER_ID = "bcf94dba-53bc-474b-a22d-e4af39aa042b"
|
||||||
|
export const TEST_MENTOR1_USER_ID = "d1f5f5a9-5b0a-4e1a-9e1a-9e9b5b5e1b1b"
|
||||||
|
export const TEST_STUDENT1_VV_USER_ID = "5ff59857-8de5-415e-a387-4449f9a0337a"
|
||||||
|
export const TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID = "7e8ebf0b-e6e2-4022-88f4-6e663ba0a9db"
|
||||||
|
export const TEST_USER_EMPTY_ID = "daecbabe-4ab9-4edf-a71f-4119042ccb02"
|
||||||
|
|
||||||
|
|
||||||
export const TEST_COURSE_SESSION_BERN_ID = -1;
|
export const TEST_COURSE_SESSION_BERN_ID = -1;
|
||||||
export const TEST_COURSE_SESSION_ZURICH_ID = -2;
|
export const TEST_COURSE_SESSION_ZURICH_ID = -2;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { TEST_USER_EMPTY_ID } from "../../consts";
|
||||||
|
import { login } from "../helpers";
|
||||||
|
|
||||||
|
describe("checkout.cy.js", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.manageCommand("cypress_reset");
|
||||||
|
|
||||||
|
login("empty@example.com", "test");
|
||||||
|
cy.visit("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can checkout and buy Versicherungsvermittlerin with credit card", () => {
|
||||||
|
cy.get('[data-cy="start-vv"]').click();
|
||||||
|
|
||||||
|
// wähle "Deutsch"
|
||||||
|
cy.get('[href="/onboarding/vv-de/account/create"]').click();
|
||||||
|
|
||||||
|
cy.get('[data-cy="account-confirm-title"]').should(
|
||||||
|
"contain",
|
||||||
|
"Konto erstellen"
|
||||||
|
);
|
||||||
|
cy.get('[data-cy="continue-button"]').click();
|
||||||
|
|
||||||
|
cy.get('[data-cy="account-profile-title"]').should(
|
||||||
|
"contain",
|
||||||
|
"Profil ergänzen"
|
||||||
|
);
|
||||||
|
cy.get('[data-cy="dropdown-select"]').click();
|
||||||
|
cy.get('[data-cy="dropdown-select-option-Baloise"]').click();
|
||||||
|
cy.get('[data-cy="continue-button"]').click();
|
||||||
|
|
||||||
|
// Adressdaten ausfüllen
|
||||||
|
cy.get('[data-cy="account-checkout-title"]').should(
|
||||||
|
"contain",
|
||||||
|
"Lehrgang kaufen"
|
||||||
|
);
|
||||||
|
cy.get("#street-address").type("Eggersmatt");
|
||||||
|
cy.get("#street-number").type("32");
|
||||||
|
cy.get("#postal-code").type("1719");
|
||||||
|
cy.get("#city").type("Zumholz");
|
||||||
|
cy.get('[data-cy="add-company-address"]').click();
|
||||||
|
|
||||||
|
cy.get("#company-name").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");
|
||||||
|
cy.get("#company-city").type("Bern");
|
||||||
|
|
||||||
|
cy.get('[data-cy="continue-pay"]').click();
|
||||||
|
|
||||||
|
// check that results are stored on server
|
||||||
|
cy.loadCheckoutInformation("user_id", TEST_USER_EMPTY_ID).then((ci) => {
|
||||||
|
expect(ci.first_name).to.equal("Flasche");
|
||||||
|
expect(ci.last_name).to.equal("Leer");
|
||||||
|
|
||||||
|
expect(ci.street).to.equal("Eggersmatt");
|
||||||
|
expect(ci.street_number).to.equal("32");
|
||||||
|
expect(ci.postal_code).to.equal("1719");
|
||||||
|
expect(ci.city).to.equal("Zumholz");
|
||||||
|
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_street).to.equal("Brückfeldstrasse");
|
||||||
|
expect(ci.organisation_street_number).to.equal("16");
|
||||||
|
expect(ci.organisation_postal_code).to.equal("3012");
|
||||||
|
expect(ci.organisation_city).to.equal("Bern");
|
||||||
|
|
||||||
|
expect(ci.product_name).to.equal("Versicherungsvermittler/-in VBV");
|
||||||
|
expect(ci.product_price).to.equal(32400);
|
||||||
|
|
||||||
|
expect(ci.state).to.equal("ongoing");
|
||||||
|
});
|
||||||
|
|
||||||
|
// pay
|
||||||
|
cy.get('[data-cy="pay-button"]').click();
|
||||||
|
|
||||||
|
cy.get('[data-cy="checkout-success-title"]').should(
|
||||||
|
"contain",
|
||||||
|
"Gratuliere"
|
||||||
|
);
|
||||||
|
// wait for payment callback
|
||||||
|
cy.wait(3000);
|
||||||
|
cy.get('[data-cy="start-vv-button"]').click();
|
||||||
|
|
||||||
|
// back on dashboard page
|
||||||
|
cy.get('[data-cy="db-course-title"]').should(
|
||||||
|
"contain",
|
||||||
|
"Versicherungsvermittler"
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.loadCheckoutInformation("user_id", TEST_USER_EMPTY_ID).then((ci) => {
|
||||||
|
expect(ci.state).to.equal("paid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can checkout and pay Versicherungsvermittlerin with Cembra invoice", () => {
|
||||||
|
cy.get('[data-cy="start-vv"]').click();
|
||||||
|
|
||||||
|
// wähle "Deutsch"
|
||||||
|
cy.get('[href="/onboarding/vv-de/account/create"]').click();
|
||||||
|
|
||||||
|
cy.get('[data-cy="account-confirm-title"]').should(
|
||||||
|
"contain",
|
||||||
|
"Konto erstellen"
|
||||||
|
);
|
||||||
|
cy.get('[data-cy="continue-button"]').click();
|
||||||
|
|
||||||
|
cy.get('[data-cy="account-profile-title"]').should(
|
||||||
|
"contain",
|
||||||
|
"Profil ergänzen"
|
||||||
|
);
|
||||||
|
cy.get('[data-cy="dropdown-select"]').click();
|
||||||
|
cy.get('[data-cy="dropdown-select-option-Baloise"]').click();
|
||||||
|
cy.get('[data-cy="continue-button"]').click();
|
||||||
|
|
||||||
|
// Adressdaten ausfüllen
|
||||||
|
cy.get('[data-cy="account-checkout-title"]').should(
|
||||||
|
"contain",
|
||||||
|
"Lehrgang kaufen"
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.get('#paymentMethod').select('cembra_byjuno');
|
||||||
|
|
||||||
|
cy.get("#street-address").type("Eggersmatt");
|
||||||
|
cy.get("#street-number").type("32");
|
||||||
|
cy.get("#postal-code").type("1719");
|
||||||
|
cy.get("#city").type("Zumholz");
|
||||||
|
|
||||||
|
cy.get("#phone").type("079 201 85 86");
|
||||||
|
cy.get('[data-test="dp-input"]').type("09.06.1982{enter}");
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
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.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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// check that results are stored on server
|
||||||
|
cy.loadCheckoutInformation("user_id", TEST_USER_EMPTY_ID).then((ci) => {
|
||||||
|
expect(ci.first_name).to.equal("Flasche");
|
||||||
|
expect(ci.last_name).to.equal("Leer");
|
||||||
|
|
||||||
|
expect(ci.street).to.equal("Eggersmatt");
|
||||||
|
expect(ci.street_number).to.equal("32");
|
||||||
|
expect(ci.postal_code).to.equal("1719");
|
||||||
|
expect(ci.city).to.equal("Zumholz");
|
||||||
|
expect(ci.country).to.equal("CH");
|
||||||
|
expect(ci.phone_number).to.equal("+41792018586");
|
||||||
|
expect(ci.birth_date).to.equal("1982-06-09");
|
||||||
|
expect(ci.cembra_byjuno_invoice).to.be.true;
|
||||||
|
|
||||||
|
expect(ci.invoice_address).to.equal("prv");
|
||||||
|
|
||||||
|
expect(ci.product_name).to.equal("Versicherungsvermittler/-in VBV");
|
||||||
|
expect(ci.product_price).to.equal(32400);
|
||||||
|
|
||||||
|
expect(ci.state).to.equal("ongoing");
|
||||||
|
|
||||||
|
expect(ci.ip_address).to.not.be.empty;
|
||||||
|
expect(ci.device_fingerprint_session_key).to.not.be.empty;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -133,7 +133,27 @@ Cypress.Commands.add("loadAssignmentCompletion", (key, value) => {
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
"vbv_lernwelt.assignment.models.AssignmentCompletion",
|
"vbv_lernwelt.assignment.models.AssignmentCompletion",
|
||||||
"vbv_lernwelt.assignment.serializers.AssignmentCompletionSerializer",
|
"vbv_lernwelt.assignment.serializers.CypressAssignmentCompletionSerializer",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add("loadSecurityRequestResponseLog", (key, value) => {
|
||||||
|
return loadObjectJson(
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
"vbv_lernwelt.core.models.SecurityRequestResponseLog",
|
||||||
|
"vbv_lernwelt.core.serializers.CypressSecurityRequestResponseLogSerializer",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add("loadExternalApiRequestLog", (key, value) => {
|
||||||
|
return loadObjectJson(
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
"vbv_lernwelt.core.models.ExternalApiRequestLog",
|
||||||
|
"vbv_lernwelt.core.serializers.CypressExternalApiRequestLogSerializer",
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -148,6 +168,16 @@ Cypress.Commands.add("loadFeedbackResponse", (key, value) => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add("loadCheckoutInformation", (key, value) => {
|
||||||
|
return loadObjectJson(
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
"vbv_lernwelt.shop.models.CheckoutInformation",
|
||||||
|
"vbv_lernwelt.shop.serializers.CypressCheckoutInformationSerializer",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
Cypress.Commands.add("makeSelfEvaluation", (answers) => {
|
Cypress.Commands.add("makeSelfEvaluation", (answers) => {
|
||||||
for (let i = 0; i < answers.length; i++) {
|
for (let i = 0; i < answers.length; i++) {
|
||||||
const answer = answers[i];
|
const answer = answers[i];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# pylint: disable=unused-wildcard-import,wildcard-import,wrong-import-position
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
|
||||||
|
script_path = os.path.abspath(__file__)
|
||||||
|
script_dir = os.path.dirname(script_path)
|
||||||
|
|
||||||
|
dev_env = dotenv_values(f"{script_dir}/../../../env_secrets/caprover_vbv-develop.env")
|
||||||
|
|
||||||
|
os.environ["IT_APP_ENVIRONMENT"] = "local"
|
||||||
|
|
||||||
|
os.environ["AWS_S3_SECRET_ACCESS_KEY"] = dev_env.get("AWS_S3_SECRET_ACCESS_KEY")
|
||||||
|
os.environ["DATATRANS_BASIC_AUTH_KEY"] = dev_env.get("DATATRANS_BASIC_AUTH_KEY")
|
||||||
|
os.environ["DATATRANS_HMAC_KEY"] = dev_env.get("DATATRANS_HMAC_KEY")
|
||||||
|
|
||||||
|
os.environ["IT_APP_ENVIRONMENT"] = "local"
|
||||||
|
os.environ["AWS_S3_SECRET_ACCESS_KEY"] = os.environ.get(
|
||||||
|
"AWS_S3_SECRET_ACCESS_KEY",
|
||||||
|
"!!!default_for_quieting_cypress_within_pycharm!!!",
|
||||||
|
)
|
||||||
|
|
||||||
|
from .test_cypress import * # noqa
|
||||||
|
|
||||||
|
DATATRANS_API_ENDPOINT = "https://api.sandbox.datatrans.com"
|
||||||
|
DATATRANS_PAY_URL = "https://pay.sandbox.datatrans.com"
|
||||||
|
|
@ -69,7 +69,7 @@ from vbv_lernwelt.importer.views import (
|
||||||
from vbv_lernwelt.media_files.views import user_image
|
from vbv_lernwelt.media_files.views import user_image
|
||||||
from vbv_lernwelt.notify.views import email_notification_settings
|
from vbv_lernwelt.notify.views import email_notification_settings
|
||||||
|
|
||||||
from vbv_lernwelt.shop.datatrans_fake_server import (
|
from vbv_lernwelt.shop.datatrans.datatrans_fake_server import (
|
||||||
fake_datatrans_api_view,
|
fake_datatrans_api_view,
|
||||||
fake_datatrans_pay_view,
|
fake_datatrans_pay_view,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -596,6 +596,10 @@ typing-extensions==4.12.2
|
||||||
# ufmt
|
# ufmt
|
||||||
# uvicorn
|
# uvicorn
|
||||||
# wagtail-localize
|
# wagtail-localize
|
||||||
|
typing-inspect==0.9.0
|
||||||
|
# via libcst
|
||||||
|
ua-parser==0.18.0
|
||||||
|
# via -r requirements.in
|
||||||
ufmt==2.7.0
|
ufmt==2.7.0
|
||||||
# via -r requirements-dev.in
|
# via -r requirements-dev.in
|
||||||
uritemplate==4.1.1
|
uritemplate==4.1.1
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ redis # https://github.com/redis/redis-py
|
||||||
uvicorn[standard] # https://github.com/encode/uvicorn
|
uvicorn[standard] # https://github.com/encode/uvicorn
|
||||||
environs
|
environs
|
||||||
click
|
click
|
||||||
|
ua-parser
|
||||||
|
|
||||||
# Django
|
# Django
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -360,6 +360,8 @@ typing-extensions==4.12.2
|
||||||
# jwcrypto
|
# jwcrypto
|
||||||
# uvicorn
|
# uvicorn
|
||||||
# wagtail-localize
|
# wagtail-localize
|
||||||
|
ua-parser==0.18.0
|
||||||
|
# via -r requirements.in
|
||||||
uritemplate==4.1.1
|
uritemplate==4.1.1
|
||||||
# via drf-spectacular
|
# via drf-spectacular
|
||||||
urllib3==2.2.2
|
urllib3==2.2.2
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ class ProfileViewTest(APITestCase):
|
||||||
# THEN
|
# THEN
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
profile = response.data
|
profile = response.data
|
||||||
self.assertEqual(
|
self.maxDiff = None
|
||||||
|
self.assertDictEqual(
|
||||||
profile,
|
profile,
|
||||||
{
|
{
|
||||||
"id": str(self.user.id),
|
"id": str(self.user.id),
|
||||||
|
|
@ -62,6 +63,8 @@ class ProfileViewTest(APITestCase):
|
||||||
"postal_code": "",
|
"postal_code": "",
|
||||||
"city": "",
|
"city": "",
|
||||||
"country": None,
|
"country": None,
|
||||||
|
"phone_number": "",
|
||||||
|
"birth_date": None,
|
||||||
"organisation_detail_name": "",
|
"organisation_detail_name": "",
|
||||||
"organisation_street": "",
|
"organisation_street": "",
|
||||||
"organisation_street_number": "",
|
"organisation_street_number": "",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from rest_framework import serializers
|
||||||
from vbv_lernwelt.assignment.models import AssignmentCompletion
|
from vbv_lernwelt.assignment.models import AssignmentCompletion
|
||||||
|
|
||||||
|
|
||||||
class AssignmentCompletionSerializer(serializers.ModelSerializer):
|
class CypressAssignmentCompletionSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = AssignmentCompletion
|
model = AssignmentCompletion
|
||||||
fields = [
|
fields = [
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@ from django.contrib import admin
|
||||||
from django.contrib.auth import admin as auth_admin, get_user_model
|
from django.contrib.auth import admin as auth_admin, get_user_model
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from vbv_lernwelt.core.models import Country, JobLog, Organisation
|
from vbv_lernwelt.core.models import (
|
||||||
|
Country,
|
||||||
|
ExternalApiRequestLog,
|
||||||
|
JobLog,
|
||||||
|
Organisation,
|
||||||
|
SecurityRequestResponseLog,
|
||||||
|
)
|
||||||
from vbv_lernwelt.core.utils import pretty_print_json
|
from vbv_lernwelt.core.utils import pretty_print_json
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
@ -54,6 +60,8 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||||
"postal_code",
|
"postal_code",
|
||||||
"city",
|
"city",
|
||||||
"country",
|
"country",
|
||||||
|
"birth_date",
|
||||||
|
"phone_number",
|
||||||
"invoice_address",
|
"invoice_address",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
@ -109,6 +117,95 @@ class JobLogAdmin(LogAdmin):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(SecurityRequestResponseLog)
|
||||||
|
class SecurityRequestResponseLogAdmin(LogAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"created",
|
||||||
|
"label",
|
||||||
|
"type",
|
||||||
|
"action",
|
||||||
|
"ref",
|
||||||
|
"request_username",
|
||||||
|
"request_full_path",
|
||||||
|
"request_method",
|
||||||
|
"response_status_code",
|
||||||
|
"request_client_ip",
|
||||||
|
"request_elapse_time",
|
||||||
|
"request_trace_id",
|
||||||
|
"session_key",
|
||||||
|
"user_agent_os",
|
||||||
|
"user_agent_browser",
|
||||||
|
)
|
||||||
|
list_filter = [
|
||||||
|
"label",
|
||||||
|
"type",
|
||||||
|
"action",
|
||||||
|
"request_full_path",
|
||||||
|
"request_method",
|
||||||
|
"response_status_code",
|
||||||
|
"user_agent_os",
|
||||||
|
"user_agent_browser",
|
||||||
|
]
|
||||||
|
search_fields = [
|
||||||
|
"request_username",
|
||||||
|
"request_full_path",
|
||||||
|
"request_client_ip",
|
||||||
|
"request_trace_id",
|
||||||
|
"additional_json_data",
|
||||||
|
]
|
||||||
|
|
||||||
|
def additional_json_data_pretty(self, instance):
|
||||||
|
return self.pretty_print_json(instance.additional_json_data)
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
return super().get_readonly_fields(request, obj) + [
|
||||||
|
"additional_json_data_pretty"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ExternalApiRequestLog)
|
||||||
|
class ExternalApiRequestLogAdmin(LogAdmin):
|
||||||
|
date_hierarchy = "created"
|
||||||
|
list_display = (
|
||||||
|
"created",
|
||||||
|
"api_request_verb",
|
||||||
|
"api_url",
|
||||||
|
"api_response_status_code",
|
||||||
|
"elapsed_time",
|
||||||
|
"request_trace_id",
|
||||||
|
"request_username",
|
||||||
|
)
|
||||||
|
search_fields = [
|
||||||
|
"request_username",
|
||||||
|
"request_trace_id",
|
||||||
|
"api_request_data",
|
||||||
|
"api_response_data",
|
||||||
|
"additional_json_data",
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
"api_response_status_code",
|
||||||
|
"api_request_verb",
|
||||||
|
"api_url",
|
||||||
|
]
|
||||||
|
|
||||||
|
def api_request_data_pretty(self, instance):
|
||||||
|
return self.pretty_print_json(instance.api_request_data)
|
||||||
|
|
||||||
|
def api_response_data_pretty(self, instance):
|
||||||
|
return self.pretty_print_json(instance.api_response_data)
|
||||||
|
|
||||||
|
def additional_data_pretty(self, instance):
|
||||||
|
return self.pretty_print_json(instance.additional_data)
|
||||||
|
|
||||||
|
def get_readonly_fields(self, request, obj=None):
|
||||||
|
return super().get_readonly_fields(request, obj) + [
|
||||||
|
"api_request_data_pretty",
|
||||||
|
"api_response_data_pretty",
|
||||||
|
"additional_data_pretty",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Organisation)
|
@admin.register(Organisation)
|
||||||
class OrganisationAdmin(admin.ModelAdmin):
|
class OrganisationAdmin(admin.ModelAdmin):
|
||||||
list_display = (
|
list_display = (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import time
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.models import ExternalApiRequestLog
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalApiRequestLogDecorator:
|
||||||
|
def __init__(self, base_url, store_request_data=True, store_response_data=True):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.store_request_data = store_request_data
|
||||||
|
self.store_response_data = store_response_data
|
||||||
|
|
||||||
|
def __call__(self, fn):
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
store_request_data = self.store_request_data
|
||||||
|
store_response_data = self.store_response_data
|
||||||
|
|
||||||
|
request_data = kwargs.get("json", None)
|
||||||
|
if not request_data:
|
||||||
|
request_data = kwargs.get("data", None)
|
||||||
|
if not request_data:
|
||||||
|
request_data = kwargs.get("params", None)
|
||||||
|
if not request_data:
|
||||||
|
request_data = args[2] if len(args) > 2 else {}
|
||||||
|
|
||||||
|
url = args[1]
|
||||||
|
context_data = kwargs.get("context_data", {}) or {}
|
||||||
|
|
||||||
|
if not store_request_data:
|
||||||
|
request_data = {"hidden": True}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"try to call subhub external api",
|
||||||
|
request_url=args[1],
|
||||||
|
request_data=request_data,
|
||||||
|
base_url=self.base_url,
|
||||||
|
label="subhub_external_communication",
|
||||||
|
)
|
||||||
|
|
||||||
|
log = ExternalApiRequestLog()
|
||||||
|
log.api_url = f"{self.base_url}{url}"
|
||||||
|
log.api_request_data = request_data
|
||||||
|
|
||||||
|
log.request_trace_id = context_data.get("request_trace_id", "")
|
||||||
|
log.request_username = context_data.get("request_username", "")
|
||||||
|
log.additional_json_data = context_data
|
||||||
|
|
||||||
|
# user = get_request_user()
|
||||||
|
# if user:
|
||||||
|
# log.user = user.username
|
||||||
|
|
||||||
|
log.save()
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
api_response = fn(*args, **kwargs)
|
||||||
|
log.elapsed_time = round(time.time() - start, 3)
|
||||||
|
|
||||||
|
log.api_request_verb = str(api_response.request.method)
|
||||||
|
log.api_response_status_code = api_response.status_code
|
||||||
|
if store_response_data:
|
||||||
|
log.api_response_data = str(api_response.text)
|
||||||
|
else:
|
||||||
|
log.api_response_data = "hidden"
|
||||||
|
|
||||||
|
log.save()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"call to subhub external api successful",
|
||||||
|
api_request_verb=log.api_request_verb,
|
||||||
|
api_url=log.api_url,
|
||||||
|
api_response_status_code=log.api_response_status_code,
|
||||||
|
api_time=log.elapsed_time,
|
||||||
|
api_request_data=log.api_request_data,
|
||||||
|
api_response_data=log.api_response_data,
|
||||||
|
label="subhub_external_communication",
|
||||||
|
)
|
||||||
|
|
||||||
|
return api_response
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
@ -3,6 +3,7 @@ from datetime import datetime
|
||||||
import djclick as click
|
import djclick as click
|
||||||
from dateutil.relativedelta import relativedelta, TU
|
from dateutil.relativedelta import relativedelta, TU
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
|
from django.db import connection
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion
|
from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion
|
||||||
|
|
@ -158,6 +159,11 @@ def command(
|
||||||
password=make_password("test"),
|
password=make_password("test"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
cursor = connection.cursor()
|
||||||
|
cursor.execute("truncate core_securityrequestresponselog;")
|
||||||
|
cursor.execute("truncate core_externalapirequestlog;")
|
||||||
|
cursor.execute("truncate django_cache_table;")
|
||||||
|
|
||||||
if create_assignment_completion or create_assignment_evaluation:
|
if create_assignment_completion or create_assignment_evaluation:
|
||||||
print("create assignment completion data for test course")
|
print("create assignment completion data for test course")
|
||||||
create_test_assignment_submitted_data(
|
create_test_assignment_submitted_data(
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from django.core.exceptions import PermissionDenied
|
|
||||||
from django.http import Http404
|
|
||||||
from ipware import get_client_ip
|
from ipware import get_client_ip
|
||||||
from structlog.threadlocal import bind_threadlocal, clear_threadlocal
|
from structlog.threadlocal import bind_threadlocal, clear_threadlocal
|
||||||
|
from ua_parser import user_agent_parser
|
||||||
|
|
||||||
from vbv_lernwelt.core.models import SecurityRequestResponseLog
|
from vbv_lernwelt.core.models import SecurityRequestResponseLog
|
||||||
|
from vbv_lernwelt.importer.utils import try_parse_int
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
@ -30,30 +31,68 @@ class SecurityRequestResponseLoggingMiddleware:
|
||||||
|
|
||||||
def create_logging_threadlocalbind(self, request):
|
def create_logging_threadlocalbind(self, request):
|
||||||
request_username = request.user.username if hasattr(request, "user") else ""
|
request_username = request.user.username if hasattr(request, "user") else ""
|
||||||
|
request_trace_id = uuid.uuid4().hex
|
||||||
bind_threadlocal(
|
bind_threadlocal(
|
||||||
request_method=request.method,
|
request_method=request.method,
|
||||||
request_full_path=request.get_full_path(),
|
request_full_path=request.get_full_path(),
|
||||||
request_username=request_username,
|
request_username=request_username,
|
||||||
request_client_ip=request.META.get("REMOTE_ADDR"),
|
request_client_ip=request.META.get("REMOTE_ADDR"),
|
||||||
request_trace_id=uuid.uuid4().hex,
|
request_trace_id=request_trace_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
def create_database_security_request_response_log(self, request, response):
|
return request_trace_id
|
||||||
|
|
||||||
|
def create_database_security_request_response_log(
|
||||||
|
self, request, response, elapsed_time, request_trace_id=""
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
entry = SecurityRequestResponseLog()
|
entry = SecurityRequestResponseLog()
|
||||||
entry.label = getattr(request, "security_request_logging", "")
|
entry.label = getattr(request, "security_request_logging", "")
|
||||||
|
entry.type = getattr(request, "type", "")
|
||||||
entry.request_method = request.method
|
entry.request_method = request.method
|
||||||
entry.request_full_path = request.get_full_path()[:255]
|
entry.request_full_path = request.get_full_path()[0:250]
|
||||||
entry.request_username = (
|
entry.request_username = (
|
||||||
request.user.username if hasattr(request, "user") else ""
|
request.user.username if hasattr(request, "user") else ""
|
||||||
)
|
)
|
||||||
entry.request_client_ip = request.META.get("REMOTE_ADDR")
|
entry.request_client_ip = request.META.get("REMOTE_ADDR")
|
||||||
entry.request_scn = getattr(request, "scn", "")
|
|
||||||
entry.response_status_code = response.status_code
|
entry.response_status_code = response.status_code
|
||||||
entry.additional_json_data = getattr(
|
entry.request_trace_id = request_trace_id
|
||||||
request, "log_additional_json_data", {}
|
entry.request_elapse_time = elapsed_time
|
||||||
)
|
entry.save()
|
||||||
|
|
||||||
|
# save predefined fields
|
||||||
|
additional_json_data = getattr(request, "log_additional_json_data", {})
|
||||||
|
|
||||||
|
entry.category = additional_json_data.pop("category", "")[0:255].strip()
|
||||||
|
entry.action = additional_json_data.pop("action", "")[0:255].strip()
|
||||||
|
entry.name = additional_json_data.pop("name", "")[0:255].strip()
|
||||||
|
entry.ref = additional_json_data.pop("ref", "")[0:255].strip()
|
||||||
|
entry.local_url = additional_json_data.pop("local_url", "")[0:255].strip()
|
||||||
|
|
||||||
|
entry.session_key = additional_json_data.pop("session_key", "")[
|
||||||
|
0:255
|
||||||
|
].strip()
|
||||||
|
_, value = try_parse_int(additional_json_data.pop("value", 0), 0)
|
||||||
|
entry.value = value
|
||||||
|
entry.save()
|
||||||
|
|
||||||
|
user_agent = request.headers.get("User-Agent", "")
|
||||||
|
entry.user_agent = user_agent[0:4096].strip()
|
||||||
|
try:
|
||||||
|
ua_parsed = user_agent_parser.Parse(user_agent)
|
||||||
|
entry.user_agent_parsed = user_agent_parser.Parse(user_agent)
|
||||||
|
entry.user_agent_os = ua_parsed.get("os").get("family")
|
||||||
|
entry.user_agent_browser = ua_parsed.get("user_agent").get("family")
|
||||||
|
# pylint: disable=broad-except
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"error while parsing user agent",
|
||||||
|
label="analytics",
|
||||||
|
user_agent=user_agent,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
entry.additional_json_data = additional_json_data
|
||||||
|
|
||||||
entry.save()
|
entry.save()
|
||||||
|
|
||||||
|
|
@ -63,18 +102,32 @@ class SecurityRequestResponseLoggingMiddleware:
|
||||||
|
|
||||||
def log_request_response(self, request):
|
def log_request_response(self, request):
|
||||||
clear_threadlocal()
|
clear_threadlocal()
|
||||||
self.create_logging_threadlocalbind(request)
|
request_trace_id = self.create_logging_threadlocalbind(request)
|
||||||
|
request.request_trace_id = request_trace_id
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"url access initialized",
|
"url access initialized",
|
||||||
label="security",
|
label="security",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
|
elapsed_time = round(time.time() - start_time, 3)
|
||||||
|
|
||||||
security_request_logging = getattr(request, "security_request_logging", None)
|
try:
|
||||||
if security_request_logging:
|
security_request_logging = getattr(
|
||||||
self.create_database_security_request_response_log(request, response)
|
request, "security_request_logging", None
|
||||||
|
)
|
||||||
|
if security_request_logging:
|
||||||
|
self.create_database_security_request_response_log(
|
||||||
|
request,
|
||||||
|
response,
|
||||||
|
elapsed_time=elapsed_time,
|
||||||
|
request_trace_id=request_trace_id,
|
||||||
|
)
|
||||||
|
# pylint: disable=broad-except
|
||||||
|
except Exception:
|
||||||
|
logger.warn("could not create db entry", label="security", exc_info=True)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"url access finished",
|
"url access finished",
|
||||||
|
|
@ -82,6 +135,7 @@ class SecurityRequestResponseLoggingMiddleware:
|
||||||
response_status_code=response.status_code,
|
response_status_code=response.status_code,
|
||||||
request_ratelimited=getattr(request, "limited", False),
|
request_ratelimited=getattr(request, "limited", False),
|
||||||
request_finished=True,
|
request_finished=True,
|
||||||
|
elapsed_time=elapsed_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
clear_threadlocal()
|
clear_threadlocal()
|
||||||
|
|
@ -90,18 +144,3 @@ class SecurityRequestResponseLoggingMiddleware:
|
||||||
|
|
||||||
def __call__(self, request):
|
def __call__(self, request):
|
||||||
return self.log_request_response(request)
|
return self.log_request_response(request)
|
||||||
|
|
||||||
def process_exception(self, request, exception):
|
|
||||||
if isinstance(exception, (Http404, PermissionDenied)):
|
|
||||||
# We don't log an exception here, and we don't set that we handled
|
|
||||||
# an error as we want the standard `request_finished` log message
|
|
||||||
# to be emitted.
|
|
||||||
return
|
|
||||||
|
|
||||||
self._raised_exception = True
|
|
||||||
|
|
||||||
logger.exception(
|
|
||||||
"request_failed",
|
|
||||||
label="security",
|
|
||||||
response_status_code=500,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 3.2.20 on 2024-06-20 14:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0009_country_refactor"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="birth_date",
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="phone_number",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Generated by Django 3.2.20 on 2024-06-21 13:10
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0010_auto_20240620_1625"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="SecurityRequestResponseLog",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
# Generated by Django 3.2.20 on 2024-06-21 14:26
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0011_delete_securityrequestresponselog"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ExternalApiRequestLog",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("api_url", models.TextField(blank=True, default="")),
|
||||||
|
("api_request_data", models.JSONField(blank=True, default=dict)),
|
||||||
|
(
|
||||||
|
"api_request_verb",
|
||||||
|
models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
("api_response_status_code", models.IntegerField(default=0)),
|
||||||
|
("api_response_data", models.TextField(blank=True, default="")),
|
||||||
|
(
|
||||||
|
"request_username",
|
||||||
|
models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"request_trace_id",
|
||||||
|
models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
("elapsed_time", models.FloatField(default=0)),
|
||||||
|
("additional_json_data", models.JSONField(blank=True, default=dict)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SecurityRequestResponseLog",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("label", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("type", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
(
|
||||||
|
"request_trace_id",
|
||||||
|
models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"request_method",
|
||||||
|
models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"request_full_path",
|
||||||
|
models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"request_username",
|
||||||
|
models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"request_client_ip",
|
||||||
|
models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
("request_elapse_time", models.FloatField(default=0)),
|
||||||
|
(
|
||||||
|
"response_status_code",
|
||||||
|
models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
("category", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("action", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("name", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("ref", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
("value", models.IntegerField(default=0)),
|
||||||
|
("local_url", models.CharField(blank=True, default="", max_length=255)),
|
||||||
|
(
|
||||||
|
"session_key",
|
||||||
|
models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
("user_agent", models.TextField(blank=True, default="")),
|
||||||
|
("user_agent_parsed", models.JSONField(blank=True, default=dict)),
|
||||||
|
(
|
||||||
|
"user_agent_os",
|
||||||
|
models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_agent_browser",
|
||||||
|
models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
("additional_json_data", models.JSONField(blank=True, default=dict)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="securityrequestresponselog",
|
||||||
|
index=models.Index(fields=["type"], name="core_securi_type_009727_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="securityrequestresponselog",
|
||||||
|
index=models.Index(fields=["label"], name="core_securi_label_dd1821_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="securityrequestresponselog",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["category"], name="core_securi_categor_1b776c_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="securityrequestresponselog",
|
||||||
|
index=models.Index(fields=["action"], name="core_securi_action_5df089_idx"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -114,10 +114,14 @@ class User(AbstractUser):
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# fields gathered from cembra pay form
|
||||||
|
birth_date = models.DateField(null=True, blank=True)
|
||||||
|
phone_number = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
|
||||||
# is only set by abacus invoice export code
|
# is only set by abacus invoice export code
|
||||||
abacus_debitor_number = models.BigIntegerField(unique=True, null=True, blank=True)
|
abacus_debitor_number = models.BigIntegerField(unique=True, null=True, blank=True)
|
||||||
|
|
||||||
def set_increment_abacus_debitor_number(self):
|
def set_increment_abacus_debitor_number(self, disable_save=False):
|
||||||
if self.abacus_debitor_number:
|
if self.abacus_debitor_number:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
@ -129,7 +133,8 @@ class User(AbstractUser):
|
||||||
current_max if current_max is not None else 60_000_000
|
current_max if current_max is not None else 60_000_000
|
||||||
) + 1
|
) + 1
|
||||||
self.abacus_debitor_number = new_debitor_number
|
self.abacus_debitor_number = new_debitor_number
|
||||||
self.save()
|
if not disable_save:
|
||||||
|
self.save()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def create_avatar_url(self, size=400):
|
def create_avatar_url(self, size=400):
|
||||||
|
|
@ -163,16 +168,63 @@ class User(AbstractUser):
|
||||||
|
|
||||||
|
|
||||||
class SecurityRequestResponseLog(models.Model):
|
class SecurityRequestResponseLog(models.Model):
|
||||||
label = models.CharField(max_length=255, blank=True, default="")
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
label = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
type = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
|
||||||
|
request_trace_id = models.CharField(max_length=255, blank=True, default="")
|
||||||
request_method = models.CharField(max_length=255, blank=True, default="")
|
request_method = models.CharField(max_length=255, blank=True, default="")
|
||||||
request_full_path = models.CharField(max_length=255, blank=True, default="")
|
request_full_path = models.CharField(max_length=255, blank=True, default="")
|
||||||
request_username = models.CharField(max_length=255, blank=True, default="")
|
request_username = models.CharField(max_length=255, blank=True, default="")
|
||||||
request_client_ip = models.CharField(max_length=255, blank=True, default="")
|
request_client_ip = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
request_elapse_time = models.FloatField(default=0)
|
||||||
|
|
||||||
response_status_code = models.CharField(max_length=255, blank=True, default="")
|
response_status_code = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
|
||||||
additional_json_data = JSONField(default=dict, blank=True)
|
category = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
action = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
name = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
ref = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
value = models.IntegerField(default=0)
|
||||||
|
local_url = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
|
||||||
|
session_key = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
|
||||||
|
user_agent = models.TextField(blank=True, default="")
|
||||||
|
user_agent_parsed = models.JSONField(blank=True, default=dict)
|
||||||
|
user_agent_os = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
user_agent_browser = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
|
||||||
|
additional_json_data = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["type"]),
|
||||||
|
models.Index(fields=["label"]),
|
||||||
|
models.Index(fields=["category"]),
|
||||||
|
models.Index(fields=["action"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalApiRequestLog(models.Model):
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
api_url = models.TextField(blank=True, default="")
|
||||||
|
api_request_data = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
api_request_verb = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
api_response_status_code = models.IntegerField(default=0)
|
||||||
|
api_response_data = models.TextField(blank=True, default="")
|
||||||
|
|
||||||
|
request_username = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
request_trace_id = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
elapsed_time = models.FloatField(default=0)
|
||||||
|
|
||||||
|
additional_json_data = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.api_request_verb} {self.api_response_status_code} {self.api_url}"
|
||||||
|
|
||||||
|
|
||||||
class JobLog(models.Model):
|
class JobLog(models.Model):
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,13 @@ from typing import List
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.renderers import JSONRenderer
|
from rest_framework.renderers import JSONRenderer
|
||||||
|
|
||||||
from vbv_lernwelt.core.models import Country, Organisation, User
|
from vbv_lernwelt.core.models import (
|
||||||
|
Country,
|
||||||
|
ExternalApiRequestLog,
|
||||||
|
Organisation,
|
||||||
|
SecurityRequestResponseLog,
|
||||||
|
User,
|
||||||
|
)
|
||||||
from vbv_lernwelt.course.models import CourseSessionUser
|
from vbv_lernwelt.course.models import CourseSessionUser
|
||||||
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
|
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
|
||||||
|
|
||||||
|
|
@ -69,6 +75,8 @@ class UserSerializer(serializers.ModelSerializer):
|
||||||
"postal_code",
|
"postal_code",
|
||||||
"city",
|
"city",
|
||||||
"country",
|
"country",
|
||||||
|
"phone_number",
|
||||||
|
"birth_date",
|
||||||
"organisation_detail_name",
|
"organisation_detail_name",
|
||||||
"organisation_street",
|
"organisation_street",
|
||||||
"organisation_street_number",
|
"organisation_street_number",
|
||||||
|
|
@ -140,3 +148,15 @@ class OrganisationSerializer(serializers.ModelSerializer):
|
||||||
return obj.name_it
|
return obj.name_it
|
||||||
|
|
||||||
return obj.name_de
|
return obj.name_de
|
||||||
|
|
||||||
|
|
||||||
|
class CypressExternalApiRequestLogSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ExternalApiRequestLog
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class CypressSecurityRequestResponseLogSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = SecurityRequestResponseLog
|
||||||
|
fields = "__all__"
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,6 @@
|
||||||
VV_DE_PRODUCT_SKU = "vv-de"
|
VV_DE_PRODUCT_SKU = "vv-de"
|
||||||
VV_FR_PRODUCT_SKU = "vv-fr"
|
VV_FR_PRODUCT_SKU = "vv-fr"
|
||||||
VV_IT_PRODUCT_SKU = "vv-it"
|
VV_IT_PRODUCT_SKU = "vv-it"
|
||||||
|
|
||||||
|
# VBV Abacus VV product number
|
||||||
|
VV_PRODUCT_NUMBER = "30202"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import structlog
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.external_request_logger import ExternalApiRequestLogDecorator
|
||||||
|
|
||||||
|
logger = structlog.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DatatransApiClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = settings.DATATRANS_API_ENDPOINT
|
||||||
|
|
||||||
|
@ExternalApiRequestLogDecorator(
|
||||||
|
base_url=settings.DATATRANS_API_ENDPOINT,
|
||||||
|
)
|
||||||
|
def _get_request(self, url, params, headers=None, context_data=None):
|
||||||
|
complete_url = self.base_url + url
|
||||||
|
return requests.get(complete_url, params=params, headers=headers)
|
||||||
|
|
||||||
|
@ExternalApiRequestLogDecorator(
|
||||||
|
base_url=settings.DATATRANS_API_ENDPOINT,
|
||||||
|
)
|
||||||
|
def _post_request(self, url, json, headers=None, context_data=None):
|
||||||
|
complete_url = self.base_url + url
|
||||||
|
return requests.post(complete_url, json=json, headers=headers)
|
||||||
|
|
||||||
|
def post_initialize_transactions(
|
||||||
|
self, json_payload: dict, context_data: dict = None
|
||||||
|
):
|
||||||
|
url = "/v1/transactions"
|
||||||
|
return self._post_request(
|
||||||
|
url,
|
||||||
|
json=json_payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {settings.DATATRANS_BASIC_AUTH_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
context_data=context_data,
|
||||||
|
)
|
||||||
|
|
@ -6,6 +6,7 @@ import time
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.db import transaction
|
||||||
from django.http import HttpResponse, JsonResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
|
@ -16,12 +17,19 @@ from vbv_lernwelt.core.models import User
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@django_view_authentication_exempt
|
@django_view_authentication_exempt
|
||||||
|
@transaction.non_atomic_requests
|
||||||
def fake_datatrans_api_view(request, api_url=""):
|
def fake_datatrans_api_view(request, api_url=""):
|
||||||
if api_url == "/v1/transactions" and request.method == "POST":
|
if api_url == "/v1/transactions" and request.method == "POST":
|
||||||
data = json.loads(request.body.decode("utf-8"))
|
data = json.loads(request.body.decode("utf-8"))
|
||||||
user = User.objects.get(id=data["user_id"])
|
user = User.objects.get(id=data["user_id"])
|
||||||
|
|
||||||
|
if "customer" in data and not user.abacus_debitor_number:
|
||||||
|
user.set_increment_abacus_debitor_number()
|
||||||
|
data["customer"]["id"] = user.abacus_debitor_number
|
||||||
|
|
||||||
user.additional_json_data["datatrans_transaction_payload"] = data
|
user.additional_json_data["datatrans_transaction_payload"] = data
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
return JsonResponse({"transactionId": data["refno"]}, status=201)
|
return JsonResponse({"transactionId": data["refno"]}, status=201)
|
||||||
|
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
|
|
@ -88,8 +96,8 @@ def fake_datatrans_pay_view(request, api_url=""):
|
||||||
<label>failed</label>
|
<label>failed</label>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button type="submit">
|
<button type="submit" data-cy="pay-button">
|
||||||
Pay with selected Status
|
Pay with selected status
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
@ -5,6 +5,7 @@ from xml.etree.ElementTree import Element, SubElement, tostring
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
|
from vbv_lernwelt.shop.const import VV_PRODUCT_NUMBER
|
||||||
from vbv_lernwelt.shop.invoice.abacus_sftp_client import AbacusSftpClient
|
from vbv_lernwelt.shop.invoice.abacus_sftp_client import AbacusSftpClient
|
||||||
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState
|
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState
|
||||||
|
|
||||||
|
|
@ -165,7 +166,7 @@ def render_invoice_xml(
|
||||||
item_fields = SubElement(item_element, "ItemFields", mode="SAVE")
|
item_fields = SubElement(item_element, "ItemFields", mode="SAVE")
|
||||||
SubElement(item_fields, "DeliveryDate").text = order_date.isoformat()
|
SubElement(item_fields, "DeliveryDate").text = order_date.isoformat()
|
||||||
SubElement(item_fields, "ItemNumber").text = "1"
|
SubElement(item_fields, "ItemNumber").text = "1"
|
||||||
SubElement(item_fields, "ProductNumber").text = "30202"
|
SubElement(item_fields, "ProductNumber").text = VV_PRODUCT_NUMBER
|
||||||
SubElement(item_fields, "QuantityOrdered").text = "1"
|
SubElement(item_fields, "QuantityOrdered").text = "1"
|
||||||
|
|
||||||
item_text = SubElement(item_element, "ItemText", mode="SAVE")
|
item_text = SubElement(item_element, "ItemText", mode="SAVE")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
# Generated by Django 3.2.20 on 2024-06-21 09:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("shop", "0014_checkoutinformation_abacus_ssh_upload_done"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="birth_date",
|
||||||
|
field=models.DateField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="cembra_byjuno_invoice",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="device_fingerprint_session_key",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="email",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="ip_address",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="phone_number",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="refno2",
|
||||||
|
field=models.CharField(blank=True, default="", max_length=255),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -62,6 +62,7 @@ class CheckoutInformation(models.Model):
|
||||||
|
|
||||||
invoice_transmitted_at = models.DateTimeField(blank=True, null=True)
|
invoice_transmitted_at = models.DateTimeField(blank=True, null=True)
|
||||||
transaction_id = models.CharField(max_length=255)
|
transaction_id = models.CharField(max_length=255)
|
||||||
|
refno2 = models.CharField(max_length=255)
|
||||||
|
|
||||||
# end user (required)
|
# end user (required)
|
||||||
first_name = models.CharField(max_length=255)
|
first_name = models.CharField(max_length=255)
|
||||||
|
|
@ -78,6 +79,16 @@ class CheckoutInformation(models.Model):
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# optional fields for cembra payment
|
||||||
|
cembra_byjuno_invoice = models.BooleanField(default=False)
|
||||||
|
birth_date = models.DateField(null=True, blank=True)
|
||||||
|
phone_number = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
email = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
device_fingerprint_session_key = models.CharField(
|
||||||
|
max_length=255, blank=True, default=""
|
||||||
|
)
|
||||||
|
ip_address = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
|
||||||
invoice_address = models.CharField(
|
invoice_address = models.CharField(
|
||||||
max_length=3, choices=INVOICE_ADDRESS_CHOICES, default="prv"
|
max_length=3, choices=INVOICE_ADDRESS_CHOICES, default="prv"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1 +1,9 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from vbv_lernwelt.shop.models import CheckoutInformation
|
||||||
|
|
||||||
|
|
||||||
|
class CypressCheckoutInformationSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = CheckoutInformation
|
||||||
|
fields = "__all__"
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,37 @@ import structlog
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from vbv_lernwelt.core.admin import User
|
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
|
from vbv_lernwelt.shop.models import CheckoutState
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_context_data_log(
|
||||||
|
request,
|
||||||
|
action: str,
|
||||||
|
):
|
||||||
|
request._request.security_request_logging = "shop_view"
|
||||||
|
request._request.type = "datatrans"
|
||||||
|
request_trace_id = getattr(request._request, "request_trace_id", "")
|
||||||
|
|
||||||
|
context_data = {
|
||||||
|
"action": action,
|
||||||
|
"session_key": (request.data.get("session_key", "") or "")[0:255].strip(),
|
||||||
|
"ref": (request.data.get("ref", "") or "")[0:255].strip(),
|
||||||
|
"request_trace_id": (request_trace_id or "")[0:255].strip(),
|
||||||
|
"request_username": (
|
||||||
|
request._request.user.username if hasattr(request._request, "user") else ""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
request._request.log_additional_json_data = context_data
|
||||||
|
|
||||||
|
log = logger.bind(label="shop", **context_data)
|
||||||
|
return context_data, log
|
||||||
|
|
||||||
|
|
||||||
class InitTransactionException(Exception):
|
class InitTransactionException(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -52,6 +78,11 @@ def init_datatrans_transaction(
|
||||||
redirect_url_error: str,
|
redirect_url_error: str,
|
||||||
redirect_url_cancel: str,
|
redirect_url_cancel: str,
|
||||||
webhook_url: str,
|
webhook_url: str,
|
||||||
|
refno2: str,
|
||||||
|
datatrans_customer_data: dict = None,
|
||||||
|
datatrans_int_data: dict = None,
|
||||||
|
context_data: dict = None,
|
||||||
|
with_cembra_byjuno_invoice: bool = False,
|
||||||
):
|
):
|
||||||
payload = {
|
payload = {
|
||||||
# We use autoSettle=True, so that we don't have to settle the transaction:
|
# We use autoSettle=True, so that we don't have to settle the transaction:
|
||||||
|
|
@ -61,6 +92,7 @@ def init_datatrans_transaction(
|
||||||
"currency": "CHF",
|
"currency": "CHF",
|
||||||
"language": user.language,
|
"language": user.language,
|
||||||
"refno": str(uuid.uuid4()),
|
"refno": str(uuid.uuid4()),
|
||||||
|
"refno2": refno2,
|
||||||
"webhook": {"url": webhook_url},
|
"webhook": {"url": webhook_url},
|
||||||
"redirect": {
|
"redirect": {
|
||||||
"successUrl": redirect_url_success,
|
"successUrl": redirect_url_success,
|
||||||
|
|
@ -69,19 +101,23 @@ def init_datatrans_transaction(
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# FIXME: test with working cembra byjuno invoice customer?
|
||||||
|
# if with_cembra_byjuno_invoice:
|
||||||
|
# payload["paymentMethods"] = ["INT"]
|
||||||
|
if datatrans_customer_data:
|
||||||
|
payload["customer"] = datatrans_customer_data
|
||||||
|
if datatrans_int_data:
|
||||||
|
payload["INT"] = datatrans_int_data
|
||||||
|
|
||||||
# add testing configuration data
|
# add testing configuration data
|
||||||
if "fakeapi" in settings.DATATRANS_API_ENDPOINT:
|
if "fakeapi" in settings.DATATRANS_API_ENDPOINT:
|
||||||
payload["user_id"] = str(user.id)
|
payload["user_id"] = str(user.id)
|
||||||
|
|
||||||
logger.info("Initiating transaction", payload=payload)
|
logger.info("Initiating transaction", payload=payload)
|
||||||
|
|
||||||
response = requests.post(
|
api_client = DatatransApiClient()
|
||||||
url=f"{settings.DATATRANS_API_ENDPOINT}/v1/transactions",
|
response = api_client.post_initialize_transactions(
|
||||||
json=payload,
|
json_payload=payload, context_data=context_data
|
||||||
headers={
|
|
||||||
"Authorization": f"Basic {settings.DATATRANS_BASIC_AUTH_KEY}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 201:
|
if response.status_code == 201:
|
||||||
|
|
|
||||||
|
|
@ -83,13 +83,22 @@ class CheckoutAPITestCase(APITestCase):
|
||||||
self.assertEqual(ci.state, "ongoing")
|
self.assertEqual(ci.state, "ongoing")
|
||||||
self.assertEqual(ci.transaction_id, "1234567890")
|
self.assertEqual(ci.transaction_id, "1234567890")
|
||||||
|
|
||||||
mock_init_transaction.assert_called_once_with(
|
mock_init_transaction.assert_called_once()
|
||||||
user=self.user,
|
call_kwargs = mock_init_transaction.call_args[1]
|
||||||
amount_chf_centimes=324_30,
|
print(call_kwargs)
|
||||||
redirect_url_success=f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/complete",
|
self.assertEqual(call_kwargs["user"], self.user)
|
||||||
redirect_url_error=f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/address?error",
|
self.assertEqual(call_kwargs["amount_chf_centimes"], 324_30)
|
||||||
redirect_url_cancel=f"{REDIRECT_URL}/",
|
self.assertEqual(
|
||||||
webhook_url=f"{REDIRECT_URL}/api/shop/transaction/webhook/",
|
call_kwargs["redirect_url_success"],
|
||||||
|
f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/complete",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
call_kwargs["redirect_url_error"],
|
||||||
|
f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/address?error",
|
||||||
|
)
|
||||||
|
self.assertEqual(call_kwargs["redirect_url_cancel"], f"{REDIRECT_URL}/")
|
||||||
|
self.assertEqual(
|
||||||
|
call_kwargs["webhook_url"], f"{REDIRECT_URL}/api/shop/transaction/webhook/"
|
||||||
)
|
)
|
||||||
|
|
||||||
@patch("vbv_lernwelt.shop.views.init_datatrans_transaction")
|
@patch("vbv_lernwelt.shop.views.init_datatrans_transaction")
|
||||||
|
|
|
||||||
|
|
@ -43,31 +43,17 @@ class DatatransServiceTest(TestCase):
|
||||||
redirect_url_error=f"{REDIRECT_URL}/error",
|
redirect_url_error=f"{REDIRECT_URL}/error",
|
||||||
redirect_url_cancel=f"{REDIRECT_URL}/cancel",
|
redirect_url_cancel=f"{REDIRECT_URL}/cancel",
|
||||||
webhook_url=f"{REDIRECT_URL}/webhook",
|
webhook_url=f"{REDIRECT_URL}/webhook",
|
||||||
|
refno2="",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(1234567890, transaction_id)
|
self.assertEqual(1234567890, transaction_id)
|
||||||
|
|
||||||
# THEN
|
# THEN
|
||||||
mock_post.assert_called_once_with(
|
mock_post.assert_called_once()
|
||||||
url="https://api.sandbox.datatrans.com/v1/transactions",
|
call_kwargs = mock_post.call_args[1]
|
||||||
json={
|
print(call_kwargs)
|
||||||
"autoSettle": True,
|
self.assertEqual(call_kwargs["json"]["autoSettle"], True)
|
||||||
"amount": 324_30,
|
self.assertEqual(call_kwargs["json"]["amount"], 324_30)
|
||||||
"currency": "CHF",
|
|
||||||
"language": self.user.language,
|
|
||||||
"refno": str(mock_uuid()),
|
|
||||||
"webhook": {"url": f"{REDIRECT_URL}/webhook"},
|
|
||||||
"redirect": {
|
|
||||||
"successUrl": f"{REDIRECT_URL}/success",
|
|
||||||
"errorUrl": f"{REDIRECT_URL}/error",
|
|
||||||
"cancelUrl": f"{REDIRECT_URL}/cancel",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
headers={
|
|
||||||
"Authorization": "Basic BASIC_AUTH_KEY",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("vbv_lernwelt.shop.services.requests.post")
|
@patch("vbv_lernwelt.shop.services.requests.post")
|
||||||
def test_init_transaction_500(self, mock_post):
|
def test_init_transaction_500(self, mock_post):
|
||||||
|
|
@ -83,6 +69,7 @@ class DatatransServiceTest(TestCase):
|
||||||
redirect_url_error=f"/error",
|
redirect_url_error=f"/error",
|
||||||
redirect_url_cancel=f"/cancel",
|
redirect_url_cancel=f"/cancel",
|
||||||
webhook_url=f"/webhook",
|
webhook_url=f"/webhook",
|
||||||
|
refno2="",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_get_payment_url(self):
|
def test_get_payment_url(self):
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
|
|
@ -11,9 +13,11 @@ from vbv_lernwelt.shop.const import (
|
||||||
VV_DE_PRODUCT_SKU,
|
VV_DE_PRODUCT_SKU,
|
||||||
VV_FR_PRODUCT_SKU,
|
VV_FR_PRODUCT_SKU,
|
||||||
VV_IT_PRODUCT_SKU,
|
VV_IT_PRODUCT_SKU,
|
||||||
|
VV_PRODUCT_NUMBER,
|
||||||
)
|
)
|
||||||
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState, Product
|
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState, Product
|
||||||
from vbv_lernwelt.shop.services import (
|
from vbv_lernwelt.shop.services import (
|
||||||
|
create_context_data_log,
|
||||||
datatrans_state_to_checkout_state,
|
datatrans_state_to_checkout_state,
|
||||||
get_payment_url,
|
get_payment_url,
|
||||||
init_datatrans_transaction,
|
init_datatrans_transaction,
|
||||||
|
|
@ -37,6 +41,10 @@ def transaction_webhook(request):
|
||||||
|
|
||||||
logger.info("Webhook: Datatrans called transaction webhook", body=request.body)
|
logger.info("Webhook: Datatrans called transaction webhook", body=request.body)
|
||||||
|
|
||||||
|
request._request.security_request_logging = "datatrans_webhook"
|
||||||
|
request._request.type = "datatrans"
|
||||||
|
request._request.log_additional_json_data = request.data
|
||||||
|
|
||||||
if not is_signature_valid(
|
if not is_signature_valid(
|
||||||
signature=request.headers.get("Datatrans-Signature", ""),
|
signature=request.headers.get("Datatrans-Signature", ""),
|
||||||
payload=request.body,
|
payload=request.body,
|
||||||
|
|
@ -80,10 +88,12 @@ def checkout_vv(request):
|
||||||
bei Browser Back redirections zu vermeiden."
|
bei Browser Back redirections zu vermeiden."
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
context_data, log = create_context_data_log(request, "checkout_vv")
|
||||||
|
|
||||||
sku = request.data["product"]
|
sku = request.data["product"]
|
||||||
base_redirect_url = request.data["redirect_url"]
|
base_redirect_url = request.data["redirect_url"]
|
||||||
|
|
||||||
logger.info(f"Checkout requested: sku", user_id=request.user.id, sku=sku)
|
log.info(f"Checkout requested: sku", user_id=request.user.id, sku=sku)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
product = Product.objects.get(sku=sku)
|
product = Product.objects.get(sku=sku)
|
||||||
|
|
@ -106,7 +116,42 @@ def checkout_vv(request):
|
||||||
if checkouts.filter(state=CheckoutState.PAID).exists():
|
if checkouts.filter(state=CheckoutState.PAID).exists():
|
||||||
return next_step_response(url="/")
|
return next_step_response(url="/")
|
||||||
|
|
||||||
|
with_cembra_byjuno_invoice = request.data.get("with_cembra_byjuno_invoice", False)
|
||||||
|
ip_address = request.META.get("REMOTE_ADDR")
|
||||||
|
email = request.user.email
|
||||||
|
|
||||||
|
request.user.set_increment_abacus_debitor_number(
|
||||||
|
disable_save="fakeapi" in settings.DATATRANS_API_ENDPOINT
|
||||||
|
)
|
||||||
|
|
||||||
|
refno2 = f"{request.user.abacus_debitor_number}_{VV_PRODUCT_NUMBER}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
datatrans_customer_data = None
|
||||||
|
datatrans_int_data = None
|
||||||
|
if with_cembra_byjuno_invoice:
|
||||||
|
# see https://api-reference.datatrans.ch/#tag/v1transactions as reference
|
||||||
|
datatrans_customer_data = {
|
||||||
|
"firstName": request.user.first_name,
|
||||||
|
"lastName": request.user.last_name,
|
||||||
|
"id": request.user.abacus_debitor_number,
|
||||||
|
"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"],
|
||||||
|
"phone": request.data["address"]["phone_number"],
|
||||||
|
"email": email,
|
||||||
|
"birthDate": request.data["address"]["birth_date"],
|
||||||
|
"language": request.user.language,
|
||||||
|
"ipAddress": ip_address,
|
||||||
|
"type": "P",
|
||||||
|
}
|
||||||
|
datatrans_int_data = {
|
||||||
|
"subtype": "INVOICE",
|
||||||
|
"riskOwner": "IJ",
|
||||||
|
"repaymentType": 3,
|
||||||
|
"deviceFingerprintId": request.data["device_fingerprint_session_key"],
|
||||||
|
}
|
||||||
transaction_id = init_datatrans_transaction(
|
transaction_id = init_datatrans_transaction(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
amount_chf_centimes=product.price,
|
amount_chf_centimes=product.price,
|
||||||
|
|
@ -118,9 +163,15 @@ def checkout_vv(request):
|
||||||
),
|
),
|
||||||
redirect_url_cancel=checkout_cancel_url(base_redirect_url),
|
redirect_url_cancel=checkout_cancel_url(base_redirect_url),
|
||||||
webhook_url=webhook_url(base_redirect_url),
|
webhook_url=webhook_url(base_redirect_url),
|
||||||
|
refno2=refno2,
|
||||||
|
datatrans_customer_data=datatrans_customer_data,
|
||||||
|
datatrans_int_data=datatrans_int_data,
|
||||||
|
context_data=context_data,
|
||||||
|
with_cembra_byjuno_invoice=with_cembra_byjuno_invoice,
|
||||||
)
|
)
|
||||||
except InitTransactionException as e:
|
except InitTransactionException as e:
|
||||||
if not settings.DEBUG:
|
if not settings.DEBUG:
|
||||||
|
log.error("Transaction initiation failed", exc_info=True, error=str(e))
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
return next_step_response(
|
return next_step_response(
|
||||||
url=checkout_error_url(
|
url=checkout_error_url(
|
||||||
|
|
@ -138,6 +189,9 @@ def checkout_vv(request):
|
||||||
organisation_country_code = address_data.pop("organisation_country_code")
|
organisation_country_code = address_data.pop("organisation_country_code")
|
||||||
address_data["organisation_country_id"] = 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(
|
checkout_info = CheckoutInformation.objects.create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
state=CheckoutState.ONGOING,
|
state=CheckoutState.ONGOING,
|
||||||
|
|
@ -147,6 +201,12 @@ def checkout_vv(request):
|
||||||
product_price=product.price,
|
product_price=product.price,
|
||||||
product_name=product.name,
|
product_name=product.name,
|
||||||
product_description=product.description,
|
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
|
# address
|
||||||
**request.data["address"],
|
**request.data["address"],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue