Merged in feature/cembra (pull request #345)

Feature/cembra Zahlung auf Rechnung
This commit is contained in:
Daniel Egger 2024-07-10 17:08:26 +00:00
commit 32274108db
45 changed files with 1523 additions and 139 deletions

View File

@ -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",

View File

@ -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",

View File

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

View File

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

View File

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

View File

@ -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}`);

View File

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

View File

@ -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") }}

View File

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

View File

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

46
client/src/statistics.ts Normal file
View File

@ -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);
}
}

View File

@ -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: "",

View File

@ -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);
});
});

69
client/src/utils/phone.ts Normal file
View File

@ -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;
}

View File

@ -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;

View File

@ -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;
});
});
});

View File

@ -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];

View File

@ -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"

View File

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

View File

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

View File

@ -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
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------

View File

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

View File

@ -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": "",

View File

@ -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 = [

View File

@ -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 = (

View File

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

View File

@ -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(

View File

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

View File

@ -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),
),
]

View File

@ -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",
),
]

View File

@ -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"),
),
]

View File

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

View File

@ -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__"

View File

@ -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"

View File

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

View File

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

View File

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

View File

@ -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),
),
]

View File

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

View File

@ -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__"

View File

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

View File

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

View File

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

View File

@ -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"],
) )