Merge branch 'develop' into feature/VBV-597-umsetzung-cockpit-lernbegleitung
This commit is contained in:
commit
480c82e466
|
|
@ -91,10 +91,10 @@ def main(app_name, image_name, environment_file):
|
||||||
"AWS_S3_ACCESS_KEY_ID", "AKIAZJLREPUVWNBTJ5VY"
|
"AWS_S3_ACCESS_KEY_ID", "AKIAZJLREPUVWNBTJ5VY"
|
||||||
),
|
),
|
||||||
"AWS_S3_SECRET_ACCESS_KEY": env.str("AWS_S3_SECRET_ACCESS_KEY", ""),
|
"AWS_S3_SECRET_ACCESS_KEY": env.str("AWS_S3_SECRET_ACCESS_KEY", ""),
|
||||||
"AWS_S3_REGION_NAME": env.str("AWS_S3_REGION_NAME", "eu-central-1"),
|
"AWS_S3_REGION_NAME": "eu-central-1",
|
||||||
"AWS_STORAGE_BUCKET_NAME": env.str(
|
"AWS_STORAGE_BUCKET_NAME": "myvbv-dev.iterativ.ch",
|
||||||
"AWS_STORAGE_BUCKET_NAME", "myvbv-dev.iterativ.ch"
|
"DATATRANS_HMAC_KEY": env.str("DATATRANS_HMAC_KEY", ""),
|
||||||
),
|
"DATATRANS_BASIC_AUTH_KEY": env.str("DATATRANS_BASIC_AUTH_KEY", ""),
|
||||||
"FILE_UPLOAD_STORAGE": "s3",
|
"FILE_UPLOAD_STORAGE": "s3",
|
||||||
"IT_DJANGO_DEBUG": "false",
|
"IT_DJANGO_DEBUG": "false",
|
||||||
"IT_SERVE_VUE": "false",
|
"IT_SERVE_VUE": "false",
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ APP_NAME=${1:-$(generate_default_app_name)}
|
||||||
export VITE_APP_ENVIRONMENT="dev-$APP_NAME"
|
export VITE_APP_ENVIRONMENT="dev-$APP_NAME"
|
||||||
|
|
||||||
if [[ "$APP_NAME" == "myvbv-stage" ]]; then
|
if [[ "$APP_NAME" == "myvbv-stage" ]]; then
|
||||||
export VITE_OAUTH_API_BASE_URL="https://vbvtst.b2clogin.com/vbvtst.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/"
|
export VITE_OAUTH_API_BASE_URL="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/"
|
||||||
export VITE_APP_ENVIRONMENT="stage-caprover"
|
export VITE_APP_ENVIRONMENT="stage-caprover"
|
||||||
elif [[ "$APP_NAME" == prod* ]]; then
|
elif [[ "$APP_NAME" == prod* ]]; then
|
||||||
export VITE_OAUTH_API_BASE_URL="https://edumgr.b2clogin.com/edumgr.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/"
|
export VITE_OAUTH_API_BASE_URL="https://edumgr.b2clogin.com/edumgr.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/"
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="flex min-h-full flex-col">
|
<div class="flex min-h-full flex-col">
|
||||||
<MainNavigationBar class="flex-none" />
|
<MainNavigationBar v-if="!route.meta.hideChrome" class="flex-none" />
|
||||||
<RouterView v-slot="{ Component }" class="flex-auto">
|
<RouterView v-slot="{ Component }" class="flex-auto">
|
||||||
<Transition mode="out-in" name="app">
|
<Transition mode="out-in" name="app">
|
||||||
<component :is="Component" :key="componentKey"></component>
|
<component :is="Component" :key="componentKey"></component>
|
||||||
</Transition>
|
</Transition>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
<AppFooter class="flex-none" />
|
<AppFooter v-if="!route.meta.hideChrome" class="flex-none" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -19,6 +19,9 @@ import { graphqlClient } from "@/graphql/client";
|
||||||
import eventBus from "@/utils/eventBus";
|
import eventBus from "@/utils/eventBus";
|
||||||
import { provideClient } from "@urql/vue";
|
import { provideClient } from "@urql/vue";
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
const componentKey = ref(1);
|
const componentKey = ref(1);
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useUserStore } from "@/stores/user";
|
||||||
|
|
||||||
|
const user = useUserStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-12">
|
||||||
|
<i18next
|
||||||
|
:translation="$t('a.Hallo {userFirstName}, wähle jetzt deinen Lehrgang aus')"
|
||||||
|
>
|
||||||
|
<template #userFirstName>{{ user.first_name }}</template>
|
||||||
|
</i18next>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-12 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/mood_vv.jpg"
|
||||||
|
:alt="$t('a.Versicherungsvermittler/-in')"
|
||||||
|
/>
|
||||||
|
<h3 class="mb-4 mt-8">{{ $t("a.Versicherungsvermittler/-in") }}</h3>
|
||||||
|
<p class="mb-4">
|
||||||
|
{{ $t("start.vvDescription") }}
|
||||||
|
</p>
|
||||||
|
<a href="/start/vv" class="btn-primary">{{ $t("a.Mehr erfahren") }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src="../../assets/images/mood_uk.jpg"
|
||||||
|
:alt="$t('Überbetriebliche Kurse')"
|
||||||
|
/>
|
||||||
|
<h3 class="mb-4 mt-8">{{ $t("a.Überbetriebliche Kurse") }}</h3>
|
||||||
|
<p class="mb-4">
|
||||||
|
{{ $t("start.ukDescription") }}
|
||||||
|
</p>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'accountProfile', params: { courseType: 'uk' } }"
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
{{ $t("a.Jetzt mit Lehrgang starten") }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import log from "loglevel";
|
import log from "loglevel";
|
||||||
|
import { getLoginURL } from "@/router/utils";
|
||||||
import AccountMenu from "@/components/header/AccountMenu.vue";
|
import AccountMenu from "@/components/header/AccountMenu.vue";
|
||||||
import MobileMenu from "@/components/header/MobileMenu.vue";
|
import MobileMenu from "@/components/header/MobileMenu.vue";
|
||||||
import NotificationPopover from "@/components/notifications/NotificationPopover.vue";
|
import NotificationPopover from "@/components/notifications/NotificationPopover.vue";
|
||||||
|
|
@ -333,7 +333,11 @@ const hasMentorManagementMenu = computed(() => {
|
||||||
</PopoverPanel>
|
</PopoverPanel>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
<div v-else><a class="" href="/login">Login</a></div>
|
<div v-else>
|
||||||
|
<a class="" :href="getLoginURL({ lang: userStore.language })">
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
default: "it-icon-info",
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: "text-inherit",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mb-8 flex items-center space-x-4 border px-8 py-4">
|
||||||
|
<component
|
||||||
|
:is="props.icon"
|
||||||
|
:class="['block aspect-square h-16 w-auto', props.color]"
|
||||||
|
/>
|
||||||
|
<slot name="content"></slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useEntities } from "@/services/onboarding";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: {
|
||||||
|
company_name: string;
|
||||||
|
company_street: string;
|
||||||
|
company_street_number: string;
|
||||||
|
company_postal_code: string;
|
||||||
|
company_city: string;
|
||||||
|
company_country: string;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
|
const { countries } = useEntities();
|
||||||
|
|
||||||
|
const orgAddress = computed({
|
||||||
|
get() {
|
||||||
|
return props.modelValue;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="my-10 grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6">
|
||||||
|
<div class="col-span-full">
|
||||||
|
<label
|
||||||
|
for="company-name"
|
||||||
|
class="block text-sm font-medium leading-6 text-gray-900"
|
||||||
|
>
|
||||||
|
{{ $t("a.Name") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="company-name"
|
||||||
|
v-model="orgAddress.company_name"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
name="company-name"
|
||||||
|
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 class="sm:col-span-4">
|
||||||
|
<label
|
||||||
|
for="company-street-address"
|
||||||
|
class="block text-sm font-medium leading-6 text-gray-900"
|
||||||
|
>
|
||||||
|
{{ $t("a.Strasse") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="company-street-address"
|
||||||
|
v-model="orgAddress.company_street"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
name="street-address"
|
||||||
|
autocomplete="street-address"
|
||||||
|
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 class="sm:col-span-2">
|
||||||
|
<label
|
||||||
|
for="company-street-number"
|
||||||
|
class="block text-sm font-medium leading-6 text-gray-900"
|
||||||
|
>
|
||||||
|
{{ $t("a.Hausnummmer") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="company-street-number"
|
||||||
|
v-model="orgAddress.company_street_number"
|
||||||
|
name="street-number"
|
||||||
|
type="text"
|
||||||
|
autocomplete="street-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 class="sm:col-span-1">
|
||||||
|
<label
|
||||||
|
for="company-postal-code"
|
||||||
|
class="block text-sm font-medium leading-6 text-gray-900"
|
||||||
|
>
|
||||||
|
{{ $t("a.PLZ") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="company-postal-code"
|
||||||
|
v-model="orgAddress.company_postal_code"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
name="postal-code"
|
||||||
|
autocomplete="postal-code"
|
||||||
|
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 class="sm:col-span-5">
|
||||||
|
<label
|
||||||
|
for="company-city"
|
||||||
|
class="block text-sm font-medium leading-6 text-gray-900"
|
||||||
|
>
|
||||||
|
{{ $t("a.Ort") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="company-city"
|
||||||
|
v-model="orgAddress.company_city"
|
||||||
|
type="text"
|
||||||
|
name="city"
|
||||||
|
required
|
||||||
|
autocomplete="address-level2"
|
||||||
|
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 class="col-span-full">
|
||||||
|
<label
|
||||||
|
for="company-country"
|
||||||
|
class="block text-sm font-medium leading-6 text-gray-900"
|
||||||
|
>
|
||||||
|
{{ $t("a.Land") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<select
|
||||||
|
id="company-country"
|
||||||
|
v-model="orgAddress.company_country"
|
||||||
|
required
|
||||||
|
name="country"
|
||||||
|
autocomplete="country-name"
|
||||||
|
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="country in countries" :key="country.id" :value="country.id">
|
||||||
|
{{ country.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useEntities } from "@/services/onboarding";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
street: string;
|
||||||
|
street_number: string;
|
||||||
|
postal_code: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
|
const { countries } = useEntities();
|
||||||
|
|
||||||
|
const address = computed({
|
||||||
|
get() {
|
||||||
|
return props.modelValue;
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
emit("update:modelValue", value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="my-10 grid grid-cols-1 gap-x-6 gap-y-6 sm:grid-cols-6">
|
||||||
|
<div class="sm:col-span-3">
|
||||||
|
<label for="first-name" class="block text-sm font-medium leading-6 text-gray-900">
|
||||||
|
{{ $t("a.Vorname") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="first-name"
|
||||||
|
v-model="address.first_name"
|
||||||
|
type="text"
|
||||||
|
name="first-name"
|
||||||
|
required
|
||||||
|
autocomplete="given-name"
|
||||||
|
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 class="sm:col-span-3">
|
||||||
|
<label for="last-name" class="block text-sm font-medium leading-6 text-gray-900">
|
||||||
|
{{ $t("a.Nachname") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="last-name"
|
||||||
|
v-model="address.last_name"
|
||||||
|
type="text"
|
||||||
|
name="last-name"
|
||||||
|
required
|
||||||
|
autocomplete="family-name"
|
||||||
|
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 class="sm:col-span-4">
|
||||||
|
<label
|
||||||
|
for="street-address"
|
||||||
|
class="block text-sm font-medium leading-6 text-gray-900"
|
||||||
|
>
|
||||||
|
{{ $t("a.Strasse") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="street-address"
|
||||||
|
v-model="address.street"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
name="street-address"
|
||||||
|
autocomplete="street-address"
|
||||||
|
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 class="sm:col-span-2">
|
||||||
|
<label
|
||||||
|
for="street-number"
|
||||||
|
class="block text-sm font-medium leading-6 text-gray-900"
|
||||||
|
>
|
||||||
|
{{ $t("a.Hausnummmer") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="street-number"
|
||||||
|
v-model="address.street_number"
|
||||||
|
name="street-number"
|
||||||
|
type="text"
|
||||||
|
autocomplete="street-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 class="sm:col-span-1">
|
||||||
|
<label
|
||||||
|
for="postal-code"
|
||||||
|
class="block text-sm font-medium leading-6 text-gray-900"
|
||||||
|
>
|
||||||
|
{{ $t("a.PLZ") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="postal-code"
|
||||||
|
v-model="address.postal_code"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
name="postal-code"
|
||||||
|
autocomplete="postal-code"
|
||||||
|
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 class="sm:col-span-5">
|
||||||
|
<label for="city" class="block text-sm font-medium leading-6 text-gray-900">
|
||||||
|
{{ $t("a.Ort") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<input
|
||||||
|
id="city"
|
||||||
|
v-model="address.city"
|
||||||
|
type="text"
|
||||||
|
name="city"
|
||||||
|
required
|
||||||
|
autocomplete="address-level2"
|
||||||
|
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 class="col-span-full">
|
||||||
|
<label for="country" class="block text-sm font-medium leading-6 text-gray-900">
|
||||||
|
{{ $t("a.Land") }}
|
||||||
|
</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<select
|
||||||
|
id="country"
|
||||||
|
v-model="address.country"
|
||||||
|
required
|
||||||
|
name="country"
|
||||||
|
autocomplete="country-name"
|
||||||
|
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="country in countries" :key="country.id" :value="country.id">
|
||||||
|
{{ country.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ItNavigationProgress from "@/components/ui/ItNavigationProgress.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
step: number;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-screen flex-col">
|
||||||
|
<div class="flex-grow scroll-smooth p-16 lg:overflow-auto">
|
||||||
|
<ItNavigationProgress :steps="3" :current-step="props.step" />
|
||||||
|
<slot name="content"></slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap justify-end gap-4 px-4 py-4 sm:px-6"
|
||||||
|
:class="{ 'border border-t': $slots.footer }"
|
||||||
|
>
|
||||||
|
<slot name="footer"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SUPPORT_LOCALES } from "@/i18nextWrapper";
|
||||||
|
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
|
||||||
|
import type { AvailableLanguages } from "@/stores/user";
|
||||||
|
import { useUserStore } from "@/stores/user";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
courseName: string;
|
||||||
|
imageUrl: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
async function changeLocale(language: AvailableLanguages) {
|
||||||
|
await userStore.setUserLanguages(language);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-between space-y-8 bg-blue-900 p-8">
|
||||||
|
<div class="flex w-full items-center justify-between">
|
||||||
|
<a href="/" class="flex justify-center">
|
||||||
|
<div class="h-8 w-16">
|
||||||
|
<it-icon-vbv class="-ml-3 -mt-6 mr-3 h-8 w-16" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-1 border-l border-white pl-3 text-2xl text-white">
|
||||||
|
{{ $t("general.title") }}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<Menu as="div" class="relative block text-left">
|
||||||
|
<div class="text-white">
|
||||||
|
<MenuButton class="text-white">
|
||||||
|
<it-icon-globe class="relative top-[2px] h-4 w-4" />
|
||||||
|
<span class="ml-2 inline">
|
||||||
|
{{ $t(`language.${userStore.language}`) }}
|
||||||
|
</span>
|
||||||
|
</MenuButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<transition
|
||||||
|
enter-active-class="transition ease-out duration-100"
|
||||||
|
enter-from-class="transform opacity-0 scale-95"
|
||||||
|
enter-to-class="transform opacity-100 scale-100"
|
||||||
|
leave-active-class="transition ease-in duration-75"
|
||||||
|
leave-from-class="transform opacity-100 scale-100"
|
||||||
|
leave-to-class="transform opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<MenuItems
|
||||||
|
class="absolute right-0 z-10 mt-2 w-56 origin-top-right bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||||
|
>
|
||||||
|
<MenuItem
|
||||||
|
v-for="locale in SUPPORT_LOCALES"
|
||||||
|
:key="locale"
|
||||||
|
v-slot="{ active }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
active ? 'bg-gray-200 text-gray-900' : 'text-gray-800',
|
||||||
|
'block w-full px-4 py-2 text-left',
|
||||||
|
]"
|
||||||
|
:data-cy="`language-selector-${locale}`"
|
||||||
|
@click="changeLocale(locale)"
|
||||||
|
>
|
||||||
|
{{ $t(`language.${locale}`) }}
|
||||||
|
</button>
|
||||||
|
</MenuItem>
|
||||||
|
</MenuItems>
|
||||||
|
</transition>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-w-xs text-white">
|
||||||
|
<img :src="imageUrl" class="aspect-square rounded-full object-cover" alt="" />
|
||||||
|
<!-- eslint-disable vue/no-v-html -->
|
||||||
|
<h3 class="mt-8 text-center" v-html="props.courseName"></h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-x-2 text-sm text-white">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="h-10 w-10 fill-none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<!-- TODO: 12.12.2023: FAQ wasn't ready yet. If you see this >4 months from now, just remove it... -->
|
||||||
|
<!-- <i18next-->
|
||||||
|
<!-- :translation="-->
|
||||||
|
<!-- $t('a.Hast du Fragen? Schau dir unsere FAQ an oder kontaktiere uns')-->
|
||||||
|
<!-- "-->
|
||||||
|
<!-- >-->
|
||||||
|
<!-- <template #faq>-->
|
||||||
|
<!-- <a class="underline" href="#">FAQ</a>-->
|
||||||
|
<!-- </template>-->
|
||||||
|
<!-- </i18next>-->
|
||||||
|
{{ $t("a.Hast du Fragen? Kontaktiere uns") }}:
|
||||||
|
<a class="underline" href="mailto:vermittler@vbv.ch">vermittler@vbv.ch</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="border-t border-b-gray-300 bg-none sm:bg-[url('/static/icons/vbv_questionmark.svg')] sm:bg-[length:400px_auto] sm:bg-right sm:bg-no-repeat"
|
||||||
|
>
|
||||||
|
<div class="container-large flex flex-row pb-4 lg:pb-10">
|
||||||
|
<div class="my-32">
|
||||||
|
<h2 class="mb-3">{{ $t("Hast du Fragen?") }}</h2>
|
||||||
|
<p class="non-italic">
|
||||||
|
<slot></slot>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
imageUrl?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
imageUrl: "",
|
||||||
|
loading: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<img
|
||||||
|
v-if="props?.imageUrl"
|
||||||
|
class="aspect-square w-[172px] rounded-full object-cover"
|
||||||
|
:class="{ 'opacity-30': props.loading }"
|
||||||
|
:src="props.imageUrl"
|
||||||
|
alt="avatar"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
:class="{ 'opacity-30': props.loading }"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="172"
|
||||||
|
height="172"
|
||||||
|
viewBox="0 0 172 172"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<mask
|
||||||
|
id="mask0"
|
||||||
|
style="mask-type: alpha"
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="172"
|
||||||
|
height="172"
|
||||||
|
>
|
||||||
|
<circle cx="86" cy="86" r="86" fill="#E0E5EC" />
|
||||||
|
</mask>
|
||||||
|
<g mask="url(#mask0)">
|
||||||
|
<circle cx="86" cy="86" r="86" fill="#E0E5EC" />
|
||||||
|
<path
|
||||||
|
fill="#B1C1CA"
|
||||||
|
d="M101.584 105.423a1.793 1.793 0 0 0-1.57.572 1.834 1.834 0 0 0-.465 1.604c1.019 5.347 4.45 12.685 14.982 17.254 1.248.539 3.176.959 5.621 1.486 9.151 1.977 24.454 5.288 28.214 19.294a1.843 1.843 0 0 0 1.545 1.351 1.84 1.84 0 0 0 1.367-.366 1.846 1.846 0 0 0 .659-1.944c-4.347-16.177-21.685-19.928-31.002-21.941-2.157-.469-4.022-.871-4.934-1.269-6.33-2.743-10.418-6.815-12.19-12.117 17.585 1.305 25.547-5.69 25.898-6.008a1.843 1.843 0 0 0-.325-2.968c-10.148-5.79-10.148-25.795-10.148-33.33 0-21.067-14.21-37.768-32.428-38.03-.173-.006-.347-.01-.52-.011-18.483.103-33.524 16.823-33.524 37.275 0 7.534 0 27.543-10.149 33.329a1.856 1.856 0 0 0-.767 2.35c.121.276.309.519.546.707.51.394 11.773 9.001 25.632 6.387-1.853 5.081-5.886 8.99-12.031 11.657-.89.387-2.663.811-4.716 1.298-9.38 2.227-26.844 6.376-31.216 22.668a1.84 1.84 0 0 0 .185 1.4 1.85 1.85 0 0 0 3.386-.445c3.8-14.176 19.266-17.845 28.502-20.043 2.315-.549 4.143-.984 5.332-1.497 10.532-4.569 13.96-11.907 14.983-17.254a1.83 1.83 0 0 0-.588-1.723 1.833 1.833 0 0 0-1.78-.383c-9.996 3.142-19.232-1.18-23.269-3.577 9.642-8.061 9.642-26.614 9.642-34.874 0-18.427 13.391-33.495 29.987-33.595l.391.022c16.367.233 28.709 14.994 28.709 34.34 0 8.264 0 26.857 9.683 34.918-3.335 1.903-10.939 4.982-23.642 3.463Z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<LoadingSpinner
|
||||||
|
v-if="props.loading"
|
||||||
|
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<div role="status">
|
<div role="status">
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="mr-2 h-8 w-8 animate-spin fill-blue-900 text-gray-200 dark:text-gray-600"
|
class="h-8 w-8 animate-spin fill-blue-900 text-gray-200 dark:text-gray-600"
|
||||||
viewBox="0 0 100 101"
|
viewBox="0 0 100 101"
|
||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
circleFlatLearningContents,
|
circleFlatLearningContents,
|
||||||
circleFlatLearningUnits,
|
circleFlatLearningUnits,
|
||||||
} from "@/services/circle";
|
} from "@/services/circle";
|
||||||
|
import { presignUpload, uploadFile } from "@/services/files";
|
||||||
import { useCompletionStore } from "@/stores/completion";
|
import { useCompletionStore } from "@/stores/completion";
|
||||||
import { useCourseSessionsStore } from "@/stores/courseSessions";
|
import { useCourseSessionsStore } from "@/stores/courseSessions";
|
||||||
import { useDashboardStore } from "@/stores/dashboard";
|
import { useDashboardStore } from "@/stores/dashboard";
|
||||||
|
|
@ -435,3 +436,30 @@ export function useCourseStatistics() {
|
||||||
|
|
||||||
return { courseSessionName, circleMeta };
|
return { courseSessionName, circleMeta };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useFileUpload() {
|
||||||
|
const error = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const fileInfo = ref({} as { id: string; name: string; url: string });
|
||||||
|
|
||||||
|
async function upload(e: Event) {
|
||||||
|
const { files } = e.target as HTMLInputElement;
|
||||||
|
if (!files?.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
error.value = false;
|
||||||
|
loading.value = true;
|
||||||
|
const file = files[0];
|
||||||
|
const presignData = await presignUpload(file);
|
||||||
|
await uploadFile(presignData.pre_sign, file);
|
||||||
|
fileInfo.value = presignData.file_info;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
error.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { upload, error, loading, fileInfo };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,10 @@ export const itDelete = (url: RequestInfo) => {
|
||||||
return itPost(url, {}, { method: "DELETE" });
|
return itPost(url, {}, { method: "DELETE" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const itPut = (url: RequestInfo, data: unknown) => {
|
||||||
|
return itPost(url, data, { method: "PUT" });
|
||||||
|
};
|
||||||
|
|
||||||
const itGetPromiseCache = new Map<string, Promise<any>>();
|
const itGetPromiseCache = new Map<string, Promise<any>>();
|
||||||
|
|
||||||
export function bustItGetCache(key?: string) {
|
export function bustItGetCache(key?: string) {
|
||||||
|
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
import DueDatesList from "@/components/dueDates/DueDatesList.vue";
|
|
||||||
import { useCourseSessionsStore } from "@/stores/courseSessions";
|
|
||||||
import { useUserStore } from "@/stores/user";
|
|
||||||
import type { CourseSession } from "@/types";
|
|
||||||
import log from "loglevel";
|
|
||||||
import { computed, onMounted } from "vue";
|
|
||||||
import { getCockpitUrl, getLearningPathUrl } from "@/utils/utils";
|
|
||||||
import LearningPathDiagram from "@/components/learningPath/LearningPathDiagram.vue";
|
|
||||||
|
|
||||||
log.debug("DashboardPage created");
|
|
||||||
|
|
||||||
const userStore = useUserStore();
|
|
||||||
const courseSessionsStore = useCourseSessionsStore();
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
log.debug("DashboardPage mounted");
|
|
||||||
});
|
|
||||||
|
|
||||||
const allDueDates = courseSessionsStore.allDueDates();
|
|
||||||
const getNextStepLink = (courseSession: CourseSession) => {
|
|
||||||
return computed(() => {
|
|
||||||
if (courseSessionsStore.hasCockpit(courseSession)) {
|
|
||||||
return getCockpitUrl(courseSession.course.slug);
|
|
||||||
}
|
|
||||||
return getLearningPathUrl(courseSession.course.slug);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col lg:flex-row">
|
|
||||||
<main class="grow bg-gray-200 lg:order-2">
|
|
||||||
<div class="container-medium mt-14">
|
|
||||||
<h1 data-cy="welcome-message">
|
|
||||||
{{ $t("dashboard.welcome", { name: userStore.first_name }) }}
|
|
||||||
</h1>
|
|
||||||
<div v-if="courseSessionsStore.uniqueCourseSessionsByCourse.length > 0">
|
|
||||||
<div class="mb-14">
|
|
||||||
<h2 class="mb-3 mt-12">{{ $t("dashboard.courses") }}</h2>
|
|
||||||
|
|
||||||
<div class="grid auto-rows-fr grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<div
|
|
||||||
v-for="courseSession in courseSessionsStore.uniqueCourseSessionsByCourse"
|
|
||||||
:key="courseSession.id"
|
|
||||||
>
|
|
||||||
<div class="bg-white p-6 md:h-full">
|
|
||||||
<h3 class="mb-4">{{ courseSession.course.title }}</h3>
|
|
||||||
<div>
|
|
||||||
<LearningPathDiagram
|
|
||||||
class="mb-4"
|
|
||||||
:course-slug="courseSession.course.slug"
|
|
||||||
:course-session-id="courseSession.id"
|
|
||||||
diagram-type="horizontalSmall"
|
|
||||||
></LearningPathDiagram>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<router-link
|
|
||||||
class="btn-blue"
|
|
||||||
:to="getNextStepLink(courseSession).value"
|
|
||||||
:data-cy="`continue-course-${courseSession.course.id}`"
|
|
||||||
>
|
|
||||||
{{ $t("general.nextStep") }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="mb-6">{{ $t("dashboard.dueDatesTitle") }}</h3>
|
|
||||||
<DueDatesList
|
|
||||||
class="bg-white p-6"
|
|
||||||
:due-dates="allDueDates"
|
|
||||||
:max-count="13"
|
|
||||||
:show-top-border="false"
|
|
||||||
:show-all-due-dates-link="true"
|
|
||||||
:show-bottom-border="true"
|
|
||||||
:show-course-session="true"
|
|
||||||
></DueDatesList>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="mb-14">
|
|
||||||
<div class="mb-12">
|
|
||||||
<h2 class="mb-3 mt-12">{{ $t("dashboard.courses") }}</h2>
|
|
||||||
<p class="mb-8">{{ $t("uk.dashboard.welcome") }}</p>
|
|
||||||
<p>{{ $t("uk.dashboard.nextSteps") }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 class="mb-3 mt-12">{{ $t("uk.dashboard.allClear") }}</h2>
|
|
||||||
<h3 class="font-normal">{{ $t("footer.contact") }}</h3>
|
|
||||||
<address class="not-italic">
|
|
||||||
<p class="non-italic">
|
|
||||||
{{ $t("uk.contact.title") }}
|
|
||||||
<br />
|
|
||||||
{{ $t("uk.contact.team") }}
|
|
||||||
<br />
|
|
||||||
{{ $t("uk.contact.address") }}
|
|
||||||
<br />
|
|
||||||
<a class="link" href="mailto:uek-support@vbv-afa.ch">
|
|
||||||
uek-support@vbv-afa.ch
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</address>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
<aside class="m-8 lg:order-1 lg:w-[343px]">
|
|
||||||
<div class="mx-auto mb-6 pb-6 text-center">
|
|
||||||
<img
|
|
||||||
class="mb-4 inline-block h-36 w-36 rounded-full"
|
|
||||||
:src="userStore.avatar_url"
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<p class="text-bold">{{ userStore.first_name }} {{ userStore.last_name }}</p>
|
|
||||||
<p>{{ userStore.email }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUserStore } from "@/stores/user";
|
import { useUserStore } from "@/stores/user";
|
||||||
import type { LoginMethod } from "@/types";
|
|
||||||
import * as log from "loglevel";
|
import * as log from "loglevel";
|
||||||
import { reactive } from "vue";
|
import { reactive } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
loginMethod: LoginMethod;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
log.debug("LoginView.vue created");
|
log.debug("LoginView.vue created");
|
||||||
log.debug(route.query);
|
log.debug(route.query);
|
||||||
|
|
||||||
|
|
@ -43,7 +38,6 @@ const userStore = useUserStore();
|
||||||
<h1 class="mb-8">{{ $t("login.login") }}</h1>
|
<h1 class="mb-8">{{ $t("login.login") }}</h1>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
v-if="loginMethod === 'local'"
|
|
||||||
class="bg-white p-4 lg:p-8"
|
class="bg-white p-4 lg:p-8"
|
||||||
@submit.prevent="
|
@submit.prevent="
|
||||||
userStore.handleLogin(
|
userStore.handleLogin(
|
||||||
|
|
@ -85,22 +79,6 @@ const userStore = useUserStore();
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div v-if="loginMethod === 'sso'" class="bg-white p-4 lg:p-8">
|
|
||||||
<p>
|
|
||||||
{{ $t("login.ssoText") }}
|
|
||||||
</p>
|
|
||||||
<p class="btn-primary mt-8">
|
|
||||||
<a :href="`/sso/login/?lang=${userStore.language}`">
|
|
||||||
{{ $t("login.ssoLogin") }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p class="mt-8">
|
|
||||||
<a href="/login-local">
|
|
||||||
{{ $t("login.demoLogin") }}
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="container-medium">
|
<div class="container-medium">
|
||||||
<h2 class="mb-8">{{ $t("footer.contact") }}</h2>
|
<h2 class="mb-8">{{ $t("footer.contact") }}</h2>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type { DashboardType } from "@/gql/graphql";
|
||||||
import SimpleCoursePage from "@/pages/dashboard/SimpleCoursePage.vue";
|
import SimpleCoursePage from "@/pages/dashboard/SimpleCoursePage.vue";
|
||||||
import LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
|
import LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
|
||||||
import CourseDetailDates from "@/components/dashboard/CourseDetailDates.vue";
|
import CourseDetailDates from "@/components/dashboard/CourseDetailDates.vue";
|
||||||
|
import NoCourseSession from "@/components/dashboard/NoCourseSession.vue";
|
||||||
|
|
||||||
const dashboardStore = useDashboardStore();
|
const dashboardStore = useDashboardStore();
|
||||||
|
|
||||||
|
|
@ -58,4 +59,5 @@ onMounted(dashboardStore.loadDashboardDetails);
|
||||||
></component>
|
></component>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
<NoCourseSession v-else class="container-medium mt-14" />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { presignUpload, uploadFile } from "@/services/files";
|
|
||||||
import type { UserDataFileInfo } from "@/types";
|
import type { UserDataFileInfo } from "@/types";
|
||||||
|
import { useFileUpload } from "@/composables";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
fileInfo: UserDataFileInfo | null;
|
fileInfo: UserDataFileInfo | null;
|
||||||
|
|
@ -10,6 +10,12 @@ const props = defineProps<{
|
||||||
const emit = defineEmits(["fileUploaded", "fileDeleted"]);
|
const emit = defineEmits(["fileUploaded", "fileDeleted"]);
|
||||||
|
|
||||||
const selectedFile = ref();
|
const selectedFile = ref();
|
||||||
|
const {
|
||||||
|
upload: uploadFile,
|
||||||
|
loading: uploadLoading,
|
||||||
|
error: uploadError,
|
||||||
|
fileInfo: uploadInfo,
|
||||||
|
} = useFileUpload();
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.fileInfo,
|
() => props.fileInfo,
|
||||||
|
|
@ -19,28 +25,11 @@ watch(
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const loading = ref(false);
|
watch(uploadInfo, (info) => {
|
||||||
const uploadError = ref(false);
|
console.log("fileInfo changed", info);
|
||||||
|
selectedFile.value = info;
|
||||||
async function fileSelected(e: Event) {
|
emit("fileUploaded", info.id);
|
||||||
const { files } = e.target as HTMLInputElement;
|
});
|
||||||
if (!files?.length) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
uploadError.value = false;
|
|
||||||
loading.value = true;
|
|
||||||
const file = files[0];
|
|
||||||
const presignData = await presignUpload(file);
|
|
||||||
await uploadFile(presignData.pre_sign, file);
|
|
||||||
selectedFile.value = presignData.file_info;
|
|
||||||
emit("fileUploaded", presignData.file_info.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
uploadError.value = true;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
selectedFile.value = null;
|
selectedFile.value = null;
|
||||||
|
|
@ -52,7 +41,7 @@ function handleDelete() {
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-2 text-xl">{{ $t("a.Datei hochladen") }}</h4>
|
<h4 class="mb-2 text-xl">{{ $t("a.Datei hochladen") }}</h4>
|
||||||
|
|
||||||
<template v-if="loading">
|
<template v-if="uploadLoading">
|
||||||
{{ $t("a.Laden...") }}
|
{{ $t("a.Laden...") }}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -68,7 +57,8 @@ function handleDelete() {
|
||||||
type="file"
|
type="file"
|
||||||
class="absolute opacity-0"
|
class="absolute opacity-0"
|
||||||
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.mov,.ppt,.pptx,.mp4"
|
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.mov,.ppt,.pptx,.mp4"
|
||||||
@change="fileSelected"
|
:disabled="uploadLoading"
|
||||||
|
@change="uploadFile"
|
||||||
/>
|
/>
|
||||||
<it-icon-document class="mr-1.5 h-7 w-7 text-blue-800" />
|
<it-icon-document class="mr-1.5 h-7 w-7 text-blue-800" />
|
||||||
{{ $t("a.Datei auswählen") }}
|
{{ $t("a.Datei auswählen") }}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import WizardPage from "@/components/onboarding/WizardPage.vue";
|
||||||
|
import { useUserStore } from "@/stores/user";
|
||||||
|
import InfoBox from "@/components/onboarding/InfoBox.vue";
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<WizardPage :step="0.5">
|
||||||
|
<template #content>
|
||||||
|
<h2 class="my-10">{{ $t("a.Konto erstellen") }}</h2>
|
||||||
|
<InfoBox color="text-green-500" icon="it-icon-check">
|
||||||
|
<template #content>
|
||||||
|
<p class="text-lg font-bold">
|
||||||
|
{{
|
||||||
|
$t("a.Du hast erfolgreich ein Konto für EMAIL erstellt.", {
|
||||||
|
email: userStore.email,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</InfoBox>
|
||||||
|
<p class="text-lg font-bold">
|
||||||
|
{{ $t("a.Mach nun weiter mit dem nächsten Schritt.") }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<router-link :to="{ name: 'accountProfile' }" class="btn-blue flex items-center">
|
||||||
|
{{ $t("general.next") }}
|
||||||
|
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</WizardPage>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import WizardPage from "@/components/onboarding/WizardPage.vue";
|
||||||
|
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useUserStore } from "@/stores/user";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { useTranslation } from "i18next-vue";
|
||||||
|
import { profileNextRoute, useEntities } from "@/services/onboarding";
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const user = useUserStore();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const { organisations } = useEntities();
|
||||||
|
|
||||||
|
const selectedOrganisation = ref({
|
||||||
|
id: 0,
|
||||||
|
name: t("a.Auswählen"),
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
organisations,
|
||||||
|
(newOrganisations) => {
|
||||||
|
if (newOrganisations) {
|
||||||
|
const userOrganisation = newOrganisations.find((c) => c.id === user.organisation);
|
||||||
|
if (userOrganisation) {
|
||||||
|
selectedOrganisation.value = userOrganisation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const validOrganisation = computed(() => {
|
||||||
|
return selectedOrganisation.value.id !== 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* TODO: We do this later (not in the first release)
|
||||||
|
const {
|
||||||
|
upload: avatarUpload,
|
||||||
|
loading: avatarLoading,
|
||||||
|
error: avatarError,
|
||||||
|
fileInfo: avatarFileInfo,
|
||||||
|
} = useFileUpload();
|
||||||
|
|
||||||
|
watch(avatarFileInfo, (info) => {
|
||||||
|
console.log("fileInfo changed", info);
|
||||||
|
})*/
|
||||||
|
|
||||||
|
watch(selectedOrganisation, async (organisation) => {
|
||||||
|
await user.setUserOrganisation(organisation.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextRoute = computed(() => {
|
||||||
|
return profileNextRoute(route.params.courseType);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<WizardPage :step="1">
|
||||||
|
<template #content>
|
||||||
|
<h2 class="my-10">{{ $t("a.Profil ergänzen") }}</h2>
|
||||||
|
|
||||||
|
<h3 class="mb-3">{{ $t("a.Gesellschaft") }}</h3>
|
||||||
|
|
||||||
|
<p class="mb-6 max-w-md hyphens-none">
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"a.Wähle hier den Namen der Gesellschaft aus, in der du arbeitest. So können dich andere Personen einfacher finden."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ItDropdownSelect v-model="selectedOrganisation" :items="organisations" />
|
||||||
|
|
||||||
|
<!--- TODO: We do this later (not in the first release)
|
||||||
|
<div class="mt-16 flex flex-col justify-between gap-12 lg:flex-row lg:gap-24">
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-3">{{ $t("a.Profilbild") }}</h3>
|
||||||
|
<p class="mb-6 max-w-md hyphens-none">
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"a.Lade ein Profilbild hoch, damit dich andere Personen auf den ersten Blick erkennen."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div class="btn-primary relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
id="upload"
|
||||||
|
type="file"
|
||||||
|
class="absolute opacity-0"
|
||||||
|
accept="image/*"
|
||||||
|
:disabled="avatarLoading"
|
||||||
|
@change="avatarUpload"
|
||||||
|
/>
|
||||||
|
{{ $t("a.Bild hochladen") }}
|
||||||
|
<it-icon-upload class="it-icon ml-2 h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p v-if="avatarError" class="mt-3 text-red-700">
|
||||||
|
{{ $t("a.Datei kann nicht gespeichert werden.") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AvatarImage :loading="avatarLoading" :image-url="user.avatar_url" />
|
||||||
|
</div>
|
||||||
|
-->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<router-link v-slot="{ navigate }" :to="{ name: nextRoute }" custom>
|
||||||
|
<button
|
||||||
|
:disabled="!validOrganisation"
|
||||||
|
class="btn-blue flex items-center"
|
||||||
|
role="link"
|
||||||
|
@click="navigate"
|
||||||
|
>
|
||||||
|
{{ $t("general.next") }}
|
||||||
|
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</WizardPage>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import WizardPage from "@/components/onboarding/WizardPage.vue";
|
||||||
|
import { useUserStore } from "@/stores/user";
|
||||||
|
import { getLoginURL } from "@/router/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
courseType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const user = useUserStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<WizardPage :step="0">
|
||||||
|
<template #content>
|
||||||
|
<h2 class="my-10">{{ $t("a.Konto erstellen") }}</h2>
|
||||||
|
<p class="mb-4">
|
||||||
|
{{ $t("a.Damit du myVBV nutzen kannst, brauchst du ein Konto.") }}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
:href="`/sso/signup?course=${props.courseType}&lang=${user.language}`"
|
||||||
|
class="btn-primary"
|
||||||
|
>
|
||||||
|
{{ $t("a.Konto erstellen") }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p class="mb-4 mt-12">{{ $t("a.Hast du schon ein Konto?") }}</p>
|
||||||
|
<a
|
||||||
|
:href="
|
||||||
|
getLoginURL({
|
||||||
|
course: props.courseType,
|
||||||
|
lang: user.language,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
{{ $t("a.Anmelden") }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</WizardPage>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import WizardSidePanel from "@/components/onboarding/WizardSidePanel.vue";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import mood_uk from "@/assets/images/mood_uk.jpg";
|
||||||
|
import mood_vv from "@/assets/images/mood_vv.jpg";
|
||||||
|
import { useTranslation } from "i18next-vue";
|
||||||
|
import { startsWith } from "lodash";
|
||||||
|
import { getVVCourseName } from "@/pages/onboarding/vv/composables";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
courseType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const courseData = computed(() => {
|
||||||
|
if (props.courseType === "uk") {
|
||||||
|
return {
|
||||||
|
name: t("a.Überbetriebliche Kurse"),
|
||||||
|
imageUrl: mood_uk,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (startsWith(props.courseType, "vv")) {
|
||||||
|
return {
|
||||||
|
name: getVVCourseName(props.courseType),
|
||||||
|
imageUrl: mood_vv,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
name: "",
|
||||||
|
imageUrl: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col lg:flex-row">
|
||||||
|
<WizardSidePanel
|
||||||
|
class="lg:w-1/3"
|
||||||
|
:image-url="courseData.imageUrl"
|
||||||
|
:course-name="courseData.name"
|
||||||
|
/>
|
||||||
|
<router-view class="lg:w-2/3" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import WizardPage from "@/components/onboarding/WizardPage.vue";
|
||||||
|
import InfoBox from "@/components/onboarding/InfoBox.vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
// This page will probably not be reached with a course session already assigned.
|
||||||
|
// This is just a placeholder for the future.
|
||||||
|
const courseSessionName = ref("");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<WizardPage :step="2">
|
||||||
|
<template #content>
|
||||||
|
<h2 class="my-10">{{ $t("a.An Durchführung teilnehmen") }}</h2>
|
||||||
|
<template v-if="courseSessionName">
|
||||||
|
<InfoBox color="text-green-500" icon="it-icon-check">
|
||||||
|
<template #content>
|
||||||
|
<p class="text-lg">
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"a.Super, nun ist alles bereit. Du bist der Durchführung «{course}» zugewiesen und kannst mit dem Lehrgang starten.",
|
||||||
|
{ course: courseSessionName }
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</InfoBox>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<InfoBox>
|
||||||
|
<template #content>
|
||||||
|
<p class="text-lg">
|
||||||
|
{{ $t("a.Aktuell bist du leider keiner Durchführung zugewiesen.") }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</InfoBox>
|
||||||
|
<p>
|
||||||
|
<i18next
|
||||||
|
:translation="
|
||||||
|
$t(
|
||||||
|
'a.Damit du mit diesem Lehrgang starten kannst, musst du einer Durchführung zugewiesen werden. Nimm dafür deinem üK-Verantwortlichen oder unserem {support} Kontakt auf.'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #support>
|
||||||
|
<a class="underline" href="mailto:help@vbv.ch">Support</a>
|
||||||
|
</template>
|
||||||
|
</i18next>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'accountProfile' }"
|
||||||
|
class="btn-secondary flex items-center"
|
||||||
|
>
|
||||||
|
<it-icon-arrow-left class="it-icon mr-2 h-6 w-6" />
|
||||||
|
{{ $t("general.back") }}
|
||||||
|
</router-link>
|
||||||
|
<router-link v-if="courseSessionName" to="/" class="btn-blue flex items-center">
|
||||||
|
{{ $t("a.Jetzt mit Lehrgang starten") }}
|
||||||
|
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
|
</WizardPage>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,324 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import WizardPage from "@/components/onboarding/WizardPage.vue";
|
||||||
|
import type { Ref } from "vue";
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useUserStore } from "@/stores/user";
|
||||||
|
import PersonalAddress from "@/components/onboarding/PersonalAddress.vue";
|
||||||
|
import OrganisationAddress from "@/components/onboarding/OrganisationAddress.vue";
|
||||||
|
import { itPost, itPut } from "@/fetchHelpers";
|
||||||
|
import { useEntities } from "@/services/onboarding";
|
||||||
|
import { useDebounceFn, useFetch } from "@vueuse/core";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import { useTranslation } from "i18next-vue";
|
||||||
|
import { getVVCourseName } from "./composables";
|
||||||
|
|
||||||
|
type BillingAddressType = {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
street: string;
|
||||||
|
street_number: string;
|
||||||
|
postal_code: string;
|
||||||
|
city: string;
|
||||||
|
country: string;
|
||||||
|
company_name: string;
|
||||||
|
company_street: string;
|
||||||
|
company_street_number: string;
|
||||||
|
company_postal_code: string;
|
||||||
|
company_city: string;
|
||||||
|
company_country: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
courseType: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = useUserStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const { organisations } = useEntities();
|
||||||
|
|
||||||
|
const userOrganisationName = computed(() => {
|
||||||
|
if (!user.organisation) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Those IDs do not represent a company
|
||||||
|
// 1: Other broker
|
||||||
|
// 2: Other insurance
|
||||||
|
// 3: Other private insurance
|
||||||
|
// 31: No company relation
|
||||||
|
if ([1, 2, 3, 31].includes(user.organisation)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return organisations.value?.find((c) => c.id === user.organisation)?.name;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentError = computed(() => {
|
||||||
|
return "error" in route.query;
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = ref({
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
street: "",
|
||||||
|
street_number: "",
|
||||||
|
postal_code: "",
|
||||||
|
city: "",
|
||||||
|
country: "",
|
||||||
|
company_name: "",
|
||||||
|
company_street: "",
|
||||||
|
company_street_number: "",
|
||||||
|
company_postal_code: "",
|
||||||
|
company_city: "",
|
||||||
|
company_country: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const useCompanyAddress = ref(false);
|
||||||
|
const fetchBillingAddress = useFetch("/api/shop/billing-address/").json();
|
||||||
|
const billingAddressData: Ref<BillingAddressType | null> = fetchBillingAddress.data;
|
||||||
|
|
||||||
|
watch(billingAddressData, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
address.value = newVal;
|
||||||
|
useCompanyAddress.value = !!newVal.company_name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAddress = useDebounceFn(() => {
|
||||||
|
itPut("/api/shop/billing-address/update/", address.value);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
address,
|
||||||
|
(newVal, oldVal) => {
|
||||||
|
if (Object.values(oldVal).every((x) => x === "")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAddress();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeCompanyAddress = () => {
|
||||||
|
useCompanyAddress.value = false;
|
||||||
|
address.value.company_name = "";
|
||||||
|
address.value.company_street = "";
|
||||||
|
address.value.company_street_number = "";
|
||||||
|
address.value.company_postal_code = "";
|
||||||
|
address.value.company_city = "";
|
||||||
|
address.value.company_country = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormErrors = {
|
||||||
|
personal: string[];
|
||||||
|
company: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const formErrors = ref<FormErrors>({
|
||||||
|
personal: [],
|
||||||
|
company: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
function validateAddress() {
|
||||||
|
formErrors.value.personal = [];
|
||||||
|
formErrors.value.company = [];
|
||||||
|
|
||||||
|
if (!address.value.first_name) {
|
||||||
|
formErrors.value.personal.push(t("a.Vorname"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address.value.last_name) {
|
||||||
|
formErrors.value.personal.push(t("a.Nachname"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address.value.street) {
|
||||||
|
formErrors.value.personal.push(t("a.Strasse"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address.value.street_number) {
|
||||||
|
formErrors.value.personal.push(t("a.Hausnummmer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address.value.postal_code) {
|
||||||
|
formErrors.value.personal.push(t("a.PLZ"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address.value.city) {
|
||||||
|
formErrors.value.personal.push(t("a.Ort"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address.value.country) {
|
||||||
|
formErrors.value.personal.push(t("a.Land"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useCompanyAddress.value) {
|
||||||
|
if (!address.value.company_name) {
|
||||||
|
formErrors.value.company.push(t("a.Name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address.value.company_street) {
|
||||||
|
formErrors.value.company.push(t("a.Strasse"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address.value.company_street_number) {
|
||||||
|
formErrors.value.company.push(t("a.Hausnummmer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address.value.company_postal_code) {
|
||||||
|
formErrors.value.company.push(t("a.PLZ"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address.value.company_city) {
|
||||||
|
formErrors.value.company.push(t("a.Ort"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!address.value.company_country) {
|
||||||
|
formErrors.value.company.push(t("a.Land"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const executePayment = () => {
|
||||||
|
validateAddress();
|
||||||
|
|
||||||
|
if (formErrors.value.personal.length > 0 || formErrors.value.company.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the payment page will redirect to after the payment is done:
|
||||||
|
// The reason why this is here is convenience: We could also do this in the backend
|
||||||
|
// then we'd need to configure this for all environments (including Caprover).
|
||||||
|
// /server/transactions/redirect?... will just redirect to the frontend to the right page
|
||||||
|
// anyway, so it seems fine to do it here.
|
||||||
|
const fullHost = `${window.location.protocol}//${window.location.host}`;
|
||||||
|
|
||||||
|
itPost("/api/shop/vv/checkout/", {
|
||||||
|
redirect_url: fullHost,
|
||||||
|
address: address.value,
|
||||||
|
product: props.courseType,
|
||||||
|
}).then((res) => {
|
||||||
|
console.log("Going to next page", res.next_step_url);
|
||||||
|
window.location.href = res.next_step_url;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<WizardPage :step="2">
|
||||||
|
<template #content>
|
||||||
|
<h2 class="my-10">{{ $t("a.Lehrgang kaufen") }}</h2>
|
||||||
|
<p class="mb-4">
|
||||||
|
<i18next
|
||||||
|
:translation="$t('a.Der Preis für den Lehrgang {course} beträgt {price}.')"
|
||||||
|
>
|
||||||
|
<template #course>
|
||||||
|
<b>«{{ getVVCourseName(props.courseType) }}»</b>
|
||||||
|
</template>
|
||||||
|
<template #price>
|
||||||
|
<b class="whitespace-nowrap">300 CHF</b>
|
||||||
|
</template>
|
||||||
|
</i18next>
|
||||||
|
{{
|
||||||
|
$t("a.Mit dem Kauf erhältst du Zugang auf den gesamten Kurs (inkl. Prüfung).")
|
||||||
|
}}
|
||||||
|
</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">
|
||||||
|
{{
|
||||||
|
$t("a.Fehler bei der Zahlung. Bitte versuche es erneut oder kontaktiere uns")
|
||||||
|
}}:
|
||||||
|
<a href="mailto:help@vbv.ch" class="underline">help@vbv.ch</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 class="mb-4 mt-10">{{ $t("a.Adresse") }}</h3>
|
||||||
|
<p class="mb-2">
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"a.Um die Zahlung vornehmen zu können, benötigen wir deine Privatadresse. Optional kannst du die Rechnungsadresse deiner Gesellschaft hinzufügen."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"Wichtig: wird die Rechnung von deinem Arbeitgeber bezahlt, dann kannst du zusätzlich die Rechnungsadresse deines Arbeitsgebers erfassen."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<PersonalAddress v-model="address" />
|
||||||
|
|
||||||
|
<p v-if="formErrors.personal.length" class="mb-10 text-red-700">
|
||||||
|
{{ $t("a.Bitte folgende Felder ausfüllen") }}:
|
||||||
|
{{ formErrors.personal.join(", ") }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="!useCompanyAddress"
|
||||||
|
class="underline"
|
||||||
|
@click="useCompanyAddress = true"
|
||||||
|
>
|
||||||
|
<template v-if="userOrganisationName">
|
||||||
|
{{
|
||||||
|
$t("a.Rechnungsadresse von {organisation} hinzufügen", {
|
||||||
|
organisation: 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}", {
|
||||||
|
organisation: userOrganisationName,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</h3>
|
||||||
|
<h3 v-else>{{ $t("a.Rechnungsadresse") }}</h3>
|
||||||
|
<button class="underline" @click="removeCompanyAddress">
|
||||||
|
{{ $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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<router-link
|
||||||
|
:to="{ name: 'accountProfile' }"
|
||||||
|
class="btn-secondary flex items-center"
|
||||||
|
>
|
||||||
|
<it-icon-arrow-left class="it-icon mr-2 h-6 w-6" />
|
||||||
|
{{ $t("general.back") }}
|
||||||
|
</router-link>
|
||||||
|
<button class="btn-blue flex items-center" @click="executePayment">
|
||||||
|
{{ $t("a.Mit Kreditkarte bezahlen") }}
|
||||||
|
<it-icon-arrow-right class="it-icon ml-2 h-6 w-6" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</WizardPage>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useUserStore } from "@/stores/user";
|
||||||
|
|
||||||
|
const user = useUserStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-grow flex-col items-center gap-y-8 p-16">
|
||||||
|
<h1 class="my-10">{{ $t("a.Gratuliere!") }}</h1>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="472"
|
||||||
|
height="259"
|
||||||
|
fill="none"
|
||||||
|
class="fill-none"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke="#0A0A0A"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="5.438"
|
||||||
|
d="M.926 107.515h20.61c48.224 0 70.888 10.673 95.319 22.664 11.679 5.738 24.948 10.319 20.652 13.487-4.296 3.168-1.781 8.307 10.17 4.976 5.152-1.441 19.21 8.81 19.21 8.81 3.222-6.268 10.102-11.298 20.421-13.868l-20.421-9.19c6.173-13.106-6.893-17.715-2.828-31.08 2.516-8.28 16.302-27.368 22.501-32.983 5.642-5.112 15.282-9.924 40.297-13.242 22.121-2.936 40.733-7.6 46.416-20.801 7.532-17.484-.15-31.202-16.424-32.548-15.308-1.278-26.905 12.889-24.567 28.034 2.339 15.146 22.99 23.697 30.468 31.936 12.331 13.569 13.256 35.118 18.218 37.769 4.962 2.651 14.248 4.976 24.187 7.953 9.938 2.978 14.003 8.13 13.024 14.126-.979 5.996-6.635 8.361-15.92 6.648-7.424-1.373-21.944-3.942-33.432-8.918-15.322-6.621-15.077-31.596-11.95-29.081 0 0-2.013 17.592-4.772 28.632-4.528 18.082-6.622 21.998-16.111 40.542-8.443 16.492-16.764 29.475-22.678 36.817-7.532 9.327-32.561 36.654-35.811 40.42-4.595 5.356 15.228 16.709-4.296 16.722-14.248.014-28.686-12.412-16.138-26.307 9.979-11.053 33.921-36.803 37.823-46.049 4.092-9.707 5.262-18.789 5.411-25.777.259-11.95-11.039-11.271-23.534 5.86l-11.488 17.334a6.887 6.887 0 0 1-8.837 2.352l-51.215-25.668c-2.637-1.319-3.779-4.5-2.529-7.165 4.895-10.455 11.298-20.135 21.114-29.584 2.271-2.189 5.697-2.665 8.511-1.251l15.798 7.899s9.504-9.693 17.566-8.619c9.326 1.25 14.37-3.25 18.68-9.545 4.31-6.294 13.541-20.406 17.144-21.834 3.603-1.428 17.511-3.984 17.511-3.984S203.5 117.97 205.091 137.33c1.659 20.339 48.618 30.264 51.051 41.086 2.298 10.224 1.224 46.946.993 54.424-.734 23.044 21.495 21.059 30.685 19.333 13.188-2.488-7.341-8.742-6.838-17.593.503-8.851 1.944-51.378 1.944-58.991 0-10.904-25.696-33.826-29.843-26.267-5.343 9.734 44.907 16.274 87.855 21.929 42.949 5.656 65.98 6.119 101.423 6.119h28.714"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p class="w-128 text-center">
|
||||||
|
<i18next
|
||||||
|
:translation="
|
||||||
|
$t('a.Die Zahlung für den Lehrgang «{course}» wurde erfolgreich ausgeführt.')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #course>
|
||||||
|
{{ $t("a.Versicherungsvermittler/-in") }}
|
||||||
|
</template>
|
||||||
|
</i18next>
|
||||||
|
</p>
|
||||||
|
<p class="w-128 text-center">
|
||||||
|
<i18next
|
||||||
|
:translation="
|
||||||
|
$t('a.Wir haben per E-Mail eine Bestätigung an {email} geschickt.')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #email>
|
||||||
|
<b>{{ user.email }}</b>
|
||||||
|
</template>
|
||||||
|
</i18next>
|
||||||
|
</p>
|
||||||
|
<router-link to="/" class="btn-blue flex items-center">
|
||||||
|
{{ $t("a.Jetzt mit Lehrgang starten") }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { useTranslation } from "i18next-vue";
|
||||||
|
|
||||||
|
export const getVVCourseName = (courseType: string) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (!["vv-de", "vv-it", "vv-fr"].includes(courseType)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookup: { [key: string]: string } = {
|
||||||
|
"vv-de": "Deutsch",
|
||||||
|
"vv-fr": "Français",
|
||||||
|
"vv-it": "Italiano",
|
||||||
|
};
|
||||||
|
|
||||||
|
const vv = t("a.Versicherungsvermittler/-in");
|
||||||
|
return `${vv} (${lookup[courseType]})`;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTranslation } from "i18next-vue";
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-gray-200">
|
||||||
|
<section class="bg-blue-900 text-white">
|
||||||
|
<div class="container-medium px-4 pb-16 pt-10 lg:px-8">
|
||||||
|
<h2>{{ $t("start.myvbvDescription") }}</h2>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<main class="lg:px-12 lg:py-12">
|
||||||
|
<div class="container-medium">
|
||||||
|
<h3 class="mb-8 text-blue-900">{{ $t("start.chooseCourse") }}</h3>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<!-- todo: enable when payment is ready
|
||||||
|
li class="mb-16 flex items-center gap-x-8">
|
||||||
|
<img
|
||||||
|
class="hidden h-72 md:block"
|
||||||
|
src="../../assets/images/mood_vv.jpg"
|
||||||
|
:alt="t('a.Versicherungsvermittler/-in')"
|
||||||
|
/>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h4 class="text-lg font-bold">
|
||||||
|
{{ $t("a.Versicherungsvermittler/-in") }}
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
{{ $t("start.vvDescription") }}
|
||||||
|
</p>
|
||||||
|
<router-link class="btn-primary" :to="{ name: 'vvStart' }">
|
||||||
|
{{ $t("a.Mehr erfahren") }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</li-->
|
||||||
|
<li class="flex items-center gap-x-8">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h4 class="text-lg font-bold">{{ $t("start.ukTitle") }}</h4>
|
||||||
|
<p>
|
||||||
|
{{ $t("start.ukDescription") }}
|
||||||
|
</p>
|
||||||
|
<router-link class="btn-primary" :to="{ name: 'ukStart' }">
|
||||||
|
{{ $t("a.Mehr erfahren") }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
class="hidden h-72 md:block"
|
||||||
|
src="../../assets/images/mood_uk.jpg"
|
||||||
|
:alt="t('start.ukTitle')"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import StartPageFooter from "@/components/start/StartPageFooter.vue";
|
||||||
|
import { useTranslation } from "i18next-vue";
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-10 bg-gray-200 pb-4 lg:pb-10">
|
||||||
|
<div class="container-large">
|
||||||
|
<nav class="py-4">
|
||||||
|
<router-link
|
||||||
|
class="btn-text inline-flex items-center pl-0"
|
||||||
|
:to="{ name: 'start' }"
|
||||||
|
>
|
||||||
|
<it-icon-arrow-left />
|
||||||
|
<span>{{ $t("general.back") }}</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
<h1 class="mb-4 lg:mb-8" data-cy="hf-title">
|
||||||
|
{{ $t("a.Überbetriebliche Kurse") }}
|
||||||
|
</h1>
|
||||||
|
<p>{{ $t("start.ukDescription") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<main class="container-large pb-4 lg:pb-10">
|
||||||
|
<div class="flex flex-row gap-10">
|
||||||
|
<img
|
||||||
|
class="hidden object-cover md:block md:h-[350px] md:w-[350px] lg:h-[500px] lg:w-[500px]"
|
||||||
|
src="../../assets/images/mood_uk.jpg"
|
||||||
|
:alt="t('start.ukTitle')"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="mx-auto mb-8 mt-0 p-0 lg:mt-8 lg:p-4">
|
||||||
|
<h2 class="mb-4 text-2xl font-semibold">
|
||||||
|
So startest du mit diesem Lehrgang:
|
||||||
|
</h2>
|
||||||
|
<ol class="circle-numbered-list">
|
||||||
|
<li>
|
||||||
|
<span class="font-bold">{{ $t("a.Konto erstellen") }}:</span>
|
||||||
|
{{ $t("a.Damit du myVBV nutzen kannst, brauchst du ein Konto.") }}
|
||||||
|
</li>
|
||||||
|
<li class="relative pl-8">
|
||||||
|
<span class="font-bold">{{ $t("a.Profil ergänzen") }}:</span>
|
||||||
|
{{
|
||||||
|
$t("Füge dein Profilbild hinzu und ergänze die fehlenden Angaben.")
|
||||||
|
}}
|
||||||
|
</li>
|
||||||
|
<li class="relative pl-8">
|
||||||
|
<span class="font-bold">{{ $t("a.An Durchführung teilnehmen") }}:</span>
|
||||||
|
{{
|
||||||
|
$t("Sobald du einer Durchführung zugewiesen bist, ist alles bereit.")
|
||||||
|
}}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<div class="mt-4 flex space-x-2">
|
||||||
|
<router-link
|
||||||
|
class="btn-primary"
|
||||||
|
:to="{ name: 'accountCreate', params: { courseType: 'uk' } }"
|
||||||
|
>
|
||||||
|
{{ $t("a.Jetzt mit Lehrgang starten") }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<StartPageFooter>
|
||||||
|
{{ $t("uk.contact.title") }}
|
||||||
|
<br />
|
||||||
|
{{ $t("uk.contact.team") }}
|
||||||
|
<br />
|
||||||
|
{{ $t("uk.contact.address") }}
|
||||||
|
<br />
|
||||||
|
<a class="link" href="mailto:uek-support@vbv-afa.ch">uek-support@vbv-afa.ch</a>
|
||||||
|
</StartPageFooter>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTranslation } from "i18next-vue";
|
||||||
|
import StartPageFooter from "@/components/start/StartPageFooter.vue";
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="mb-10 bg-gray-200 pb-4 lg:pb-10">
|
||||||
|
<div class="container-large">
|
||||||
|
<nav class="py-4">
|
||||||
|
<router-link
|
||||||
|
class="btn-text inline-flex items-center pl-0"
|
||||||
|
:to="{ name: 'start' }"
|
||||||
|
>
|
||||||
|
<it-icon-arrow-left />
|
||||||
|
<span>{{ $t("general.back") }}</span>
|
||||||
|
</router-link>
|
||||||
|
</nav>
|
||||||
|
<h1 class="mb-4 lg:mb-8" data-cy="hf-title">
|
||||||
|
{{ $t("a.Versicherungsvermittler/-in") }}
|
||||||
|
</h1>
|
||||||
|
<p>{{ $t("start.vvDescription") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<main class="container-large pb-4 lg:pb-10">
|
||||||
|
<div class="flex flex-row gap-10">
|
||||||
|
<img
|
||||||
|
class="hidden object-cover md:block md:h-[350px] md:w-[350px] lg:h-[500px] lg:w-[500px]"
|
||||||
|
src="../../assets/images/mood_vv.jpg"
|
||||||
|
:alt="t('a.Versicherungsvermittler/-in')"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div class="mx-auto mb-8 mt-0 p-0 lg:mt-8 lg:p-4">
|
||||||
|
<h2 class="mb-4 text-2xl font-semibold">
|
||||||
|
So startest du mit diesem Lehrgang:
|
||||||
|
</h2>
|
||||||
|
<ol class="circle-numbered-list">
|
||||||
|
<li>
|
||||||
|
<span class="font-bold">{{ $t("a.Konto erstellen") }}:</span>
|
||||||
|
{{ $t("a.Damit du myVBV nutzen kannst, brauchst du ein Konto.") }}
|
||||||
|
</li>
|
||||||
|
<li class="relative pl-8">
|
||||||
|
<span class="font-bold">{{ $t("a.Profil ergänzen") }}:</span>
|
||||||
|
{{
|
||||||
|
$t("Füge dein Profilbild hinzu und ergänze die fehlenden Angaben.")
|
||||||
|
}}
|
||||||
|
</li>
|
||||||
|
<li class="relative pl-8">
|
||||||
|
<span class="font-bold">{{ $t("a.Lehrgang kaufen") }}:</span>
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"Der Preis für den Lehrgang «Versicherungsvermittler-/in VBV» beträgt CHF 300 exkl. MWSt.. Mit dem Kauf erhältst du Zugang zum Lernpfad und den Lernmedien."
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<div class="mt-4">
|
||||||
|
<p class="mb-2">{{ $t("Sprache wählen und Lehrgang starten") }}:</p>
|
||||||
|
<div class="flex flex-col gap-4 md:flex-row">
|
||||||
|
<router-link
|
||||||
|
class="btn-primary"
|
||||||
|
:to="{ name: 'accountCreate', params: { courseType: 'vv-de' } }"
|
||||||
|
>
|
||||||
|
{{ $t("a.Deutsch") }}
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
class="btn-primary"
|
||||||
|
:to="{ name: 'accountCreate', params: { courseType: 'vv-fr' } }"
|
||||||
|
>
|
||||||
|
{{ $t("a.Franzosisch") }}
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
class="btn-primary"
|
||||||
|
:to="{ name: 'accountCreate', params: { courseType: 'vv-it' } }"
|
||||||
|
>
|
||||||
|
{{ $t("a.Italienisch") }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<StartPageFooter>
|
||||||
|
{{ $t("uk.contact.title") }}
|
||||||
|
<br />
|
||||||
|
{{ $t("vv.contact.team") }}
|
||||||
|
<br />
|
||||||
|
{{ $t("uk.contact.address") }}
|
||||||
|
<br />
|
||||||
|
<a class="link" href="mailto:vermittler@vbv-afa.ch">vermittler@vbv-afa.ch</a>
|
||||||
|
</StartPageFooter>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { createPinia, setActivePinia } from "pinia";
|
||||||
|
import { beforeEach, describe, expect, vi } from "vitest";
|
||||||
|
|
||||||
|
import { START_LOCATION } from "vue-router";
|
||||||
|
import { useUserStore } from "../../stores/user";
|
||||||
|
import { onboardingRedirect } from "../onboarding";
|
||||||
|
|
||||||
|
describe("Onboarding", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirect guest", () => {
|
||||||
|
const user = useUserStore();
|
||||||
|
user.loggedIn = false;
|
||||||
|
const mock = vi.fn();
|
||||||
|
onboardingRedirect(routeLocation("accountConfirm", "uk"), START_LOCATION, mock);
|
||||||
|
expect(mock).toHaveBeenCalledWith({
|
||||||
|
name: "accountCreate",
|
||||||
|
params: { courseType: "uk" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("redirect logged-in user from accountCreate to accountConfirm", () => {
|
||||||
|
const user = useUserStore();
|
||||||
|
user.loggedIn = true;
|
||||||
|
const mockNext = vi.fn();
|
||||||
|
onboardingRedirect(routeLocation("accountCreate", "uk"), START_LOCATION, mockNext);
|
||||||
|
expect(mockNext).toHaveBeenCalledWith({
|
||||||
|
name: "accountConfirm",
|
||||||
|
params: { courseType: "uk" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("UK: redirect to profile next route for logged-in user with organisation", () => {
|
||||||
|
const user = useUserStore();
|
||||||
|
user.loggedIn = true;
|
||||||
|
user.organisation = 1;
|
||||||
|
const mockNext = vi.fn();
|
||||||
|
onboardingRedirect(routeLocation("accountProfile", "uk"), START_LOCATION, mockNext);
|
||||||
|
expect(mockNext).toHaveBeenCalledWith({
|
||||||
|
name: "setupComplete",
|
||||||
|
params: { courseType: "uk" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("VV: redirect to profile next route for logged-in user with organisation", () => {
|
||||||
|
const testCases = ["vv-de", "vv-it", "vv-fr"];
|
||||||
|
testCases.forEach((testCase) => {
|
||||||
|
const user = useUserStore();
|
||||||
|
user.loggedIn = true;
|
||||||
|
user.organisation = 1;
|
||||||
|
const mockNext = vi.fn();
|
||||||
|
onboardingRedirect(
|
||||||
|
routeLocation("accountProfile", testCase),
|
||||||
|
START_LOCATION,
|
||||||
|
mockNext
|
||||||
|
);
|
||||||
|
expect(mockNext).toHaveBeenCalledWith({
|
||||||
|
name: "checkoutAddress",
|
||||||
|
params: { courseType: testCase },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no redirect for logged-in user without organisation to accountConfirm", () => {
|
||||||
|
const user = useUserStore();
|
||||||
|
user.loggedIn = true;
|
||||||
|
user.organisation = null;
|
||||||
|
const mockNext = vi.fn();
|
||||||
|
onboardingRedirect(routeLocation("accountConfirm", "uk"), START_LOCATION, mockNext);
|
||||||
|
expect(mockNext).toHaveBeenCalledWith(); // No arguments passed means no redirection
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no redirect for logged-in user to a non-relevant route", () => {
|
||||||
|
const user = useUserStore();
|
||||||
|
user.loggedIn = true;
|
||||||
|
const mockNext = vi.fn();
|
||||||
|
onboardingRedirect(routeLocation("someOtherRoute", "uk"), START_LOCATION, mockNext);
|
||||||
|
expect(mockNext).toHaveBeenCalledWith(); // No arguments passed means no redirection
|
||||||
|
});
|
||||||
|
|
||||||
|
it("no redirect for guest on accountCreate page", () => {
|
||||||
|
const user = useUserStore();
|
||||||
|
user.loggedIn = false;
|
||||||
|
const mockNext = vi.fn();
|
||||||
|
onboardingRedirect(routeLocation("accountCreate", "uk"), START_LOCATION, mockNext);
|
||||||
|
expect(mockNext).toHaveBeenCalledWith(); // No arguments passed means no redirection
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function routeLocation(name: string, courseType: string) {
|
||||||
|
return {
|
||||||
|
fullPath: "",
|
||||||
|
hash: "",
|
||||||
|
matched: [],
|
||||||
|
meta: {},
|
||||||
|
name: name,
|
||||||
|
params: {
|
||||||
|
courseType,
|
||||||
|
},
|
||||||
|
path: "",
|
||||||
|
query: {},
|
||||||
|
redirectedFrom: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { getLoginURLNext, shouldUseSSO } from "@/router/utils";
|
||||||
import { useCockpitStore } from "@/stores/cockpit";
|
import { useCockpitStore } from "@/stores/cockpit";
|
||||||
import { useCourseSessionsStore } from "@/stores/courseSessions";
|
import { useCourseSessionsStore } from "@/stores/courseSessions";
|
||||||
import { useUserStore } from "@/stores/user";
|
import { useUserStore } from "@/stores/user";
|
||||||
|
|
@ -13,14 +14,26 @@ export const updateLoggedIn: NavigationGuard = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const redirectToLoginIfRequired: NavigationGuard = (to) => {
|
export const redirectToLoginIfRequired: NavigationGuard = (to, from, next) => {
|
||||||
const userStore = useUserStore();
|
const user = useUserStore();
|
||||||
if (loginRequired(to) && !userStore.loggedIn) {
|
|
||||||
const appEnv = import.meta.env.VITE_APP_ENVIRONMENT || "local";
|
// redirect guests to /start if they access /
|
||||||
const ssoLogin = appEnv.startsWith("prod") || appEnv.startsWith("stage");
|
if (!user.loggedIn && to.path === "/") {
|
||||||
return ssoLogin
|
return next("/start");
|
||||||
? `/login?next=${encodeURIComponent(to.fullPath)}`
|
}
|
||||||
: `/login-local?next=${encodeURIComponent(to.fullPath)}`;
|
|
||||||
|
if (loginRequired(to) && !user.loggedIn) {
|
||||||
|
const loginURL = getLoginURLNext();
|
||||||
|
if (shouldUseSSO()) {
|
||||||
|
// Redirect to SSO login page, handled by the server
|
||||||
|
window.location.href = loginURL;
|
||||||
|
} else {
|
||||||
|
// Handle local login with Vue router
|
||||||
|
next(loginURL);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If login is not required or user is already logged in, continue with the navigation
|
||||||
|
next();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import DashboardPage from "@/pages/dashboard/DashboardPage.vue";
|
|
||||||
import LoginPage from "@/pages/LoginPage.vue";
|
import LoginPage from "@/pages/LoginPage.vue";
|
||||||
|
import DashboardPage from "@/pages/dashboard/DashboardPage.vue";
|
||||||
|
import GuestStartPage from "@/pages/start/GuestStartPage.vue";
|
||||||
|
import UKStartPage from "@/pages/start/UKStartPage.vue";
|
||||||
|
import VVStartPage from "@/pages/start/VVStartPage.vue";
|
||||||
import {
|
import {
|
||||||
handleAcceptLearningMentorInvitation,
|
handleAcceptLearningMentorInvitation,
|
||||||
handleCockpit,
|
handleCockpit,
|
||||||
|
|
@ -9,6 +12,7 @@ import {
|
||||||
updateLoggedIn,
|
updateLoggedIn,
|
||||||
} from "@/router/guards";
|
} from "@/router/guards";
|
||||||
import { addToHistory } from "@/router/history";
|
import { addToHistory } from "@/router/history";
|
||||||
|
import { onboardingRedirect } from "@/router/onboarding";
|
||||||
import { createRouter, createWebHistory } from "vue-router";
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|
@ -22,20 +26,33 @@ const router = createRouter({
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: "/login",
|
path: "/start",
|
||||||
component: LoginPage,
|
name: "start",
|
||||||
props: { loginMethod: "sso" },
|
component: GuestStartPage,
|
||||||
|
meta: {
|
||||||
|
public: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/start/vv",
|
||||||
|
component: VVStartPage,
|
||||||
|
name: "vvStart",
|
||||||
|
meta: {
|
||||||
|
public: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/start/uk",
|
||||||
|
component: UKStartPage,
|
||||||
|
name: "ukStart",
|
||||||
meta: {
|
meta: {
|
||||||
// no login required -> so `public === true`
|
|
||||||
public: true,
|
public: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/login-local",
|
path: "/login-local",
|
||||||
component: LoginPage,
|
component: LoginPage,
|
||||||
props: { loginMethod: "local" },
|
|
||||||
meta: {
|
meta: {
|
||||||
// no login required -> so `public === true`
|
|
||||||
public: true,
|
public: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -300,6 +317,50 @@ const router = createRouter({
|
||||||
path: "/course/:courseSlug/appointments",
|
path: "/course/:courseSlug/appointments",
|
||||||
component: () => import("@/pages/AppointmentsPage.vue"),
|
component: () => import("@/pages/AppointmentsPage.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/onboarding/:courseType",
|
||||||
|
props: true,
|
||||||
|
component: () => import("@/pages/onboarding/WizardBase.vue"),
|
||||||
|
meta: {
|
||||||
|
public: true,
|
||||||
|
hideChrome: true,
|
||||||
|
},
|
||||||
|
beforeEnter: onboardingRedirect,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "account/create",
|
||||||
|
component: () => import("@/pages/onboarding/AccountSetup.vue"),
|
||||||
|
name: "accountCreate",
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "account/confirm",
|
||||||
|
component: () => import("@/pages/onboarding/AccountConfirm.vue"),
|
||||||
|
name: "accountConfirm",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "account/profile",
|
||||||
|
component: () => import("@/pages/onboarding/AccountProfile.vue"),
|
||||||
|
name: "accountProfile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "account/complete",
|
||||||
|
component: () => import("@/pages/onboarding/uk/SetupComplete.vue"),
|
||||||
|
name: "setupComplete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "checkout/address",
|
||||||
|
component: () => import("@/pages/onboarding/vv/CheckoutAddress.vue"),
|
||||||
|
name: "checkoutAddress",
|
||||||
|
props: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "checkout/complete",
|
||||||
|
component: () => import("@/pages/onboarding/vv/CheckoutComplete.vue"),
|
||||||
|
name: "checkoutComplete",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/styleguide",
|
path: "/styleguide",
|
||||||
component: () => import("../pages/StyleGuidePage.vue"),
|
component: () => import("../pages/StyleGuidePage.vue"),
|
||||||
|
|
@ -307,6 +368,7 @@ const router = createRouter({
|
||||||
public: true,
|
public: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "/:pathMatch(.*)*",
|
path: "/:pathMatch(.*)*",
|
||||||
component: () => import("../pages/404Page.vue"),
|
component: () => import("../pages/404Page.vue"),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { profileNextRoute } from "@/services/onboarding";
|
||||||
|
import { useUserStore } from "@/stores/user";
|
||||||
|
import type { NavigationGuardNext, RouteLocationNormalized } from "vue-router";
|
||||||
|
import { START_LOCATION } from "vue-router";
|
||||||
|
|
||||||
|
export async function onboardingRedirect(
|
||||||
|
to: RouteLocationNormalized,
|
||||||
|
from: RouteLocationNormalized,
|
||||||
|
next: NavigationGuardNext
|
||||||
|
) {
|
||||||
|
const user = useUserStore();
|
||||||
|
|
||||||
|
// Guest
|
||||||
|
if (!user.loggedIn) {
|
||||||
|
if (to.name !== "accountCreate") {
|
||||||
|
return next({ name: "accountCreate", params: to.params });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Member
|
||||||
|
if (to.name === "accountCreate") {
|
||||||
|
return next({ name: "accountConfirm", params: to.params });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.name === "accountConfirm") {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maybe we can skip the profile step
|
||||||
|
if (from === START_LOCATION && user.organisation && to.name === "accountProfile") {
|
||||||
|
return next({ name: profileNextRoute(to.params.courseType), params: to.params });
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
export function shouldUseSSO() {
|
||||||
|
const appEnv = import.meta.env.VITE_APP_ENVIRONMENT || "local";
|
||||||
|
return appEnv.startsWith("prod") || appEnv.startsWith("stage");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLoginURL(params = {}) {
|
||||||
|
let url = shouldUseSSO() ? "/sso/login/" : "/login-local";
|
||||||
|
|
||||||
|
const queryParams = new URLSearchParams(params);
|
||||||
|
if (queryParams.toString()) {
|
||||||
|
url += `?${queryParams}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLoginURLNext() {
|
||||||
|
return getLoginURL({ next: window.location.pathname });
|
||||||
|
}
|
||||||
|
|
@ -107,7 +107,19 @@ export async function fetchCourseSessionDocuments(courseSessionId: string) {
|
||||||
return itGetCached(`/api/core/document/list/${courseSessionId}/`);
|
return itGetCached(`/api/core/document/list/${courseSessionId}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function presignUpload(file: File) {
|
type PresignResponse = {
|
||||||
|
pre_sign: {
|
||||||
|
url: string;
|
||||||
|
fields: Record<string, string>;
|
||||||
|
};
|
||||||
|
file_info: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function presignUpload(file: File): Promise<PresignResponse> {
|
||||||
return await itPost(`/api/core/storage/presign/`, {
|
return await itPost(`/api/core/storage/presign/`, {
|
||||||
file_type: file.type,
|
file_type: file.type,
|
||||||
file_name: file.name,
|
file_name: file.name,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { itGetCached } from "@/fetchHelpers";
|
||||||
|
import { useUserStore } from "@/stores/user";
|
||||||
|
import { isString, startsWith } from "lodash";
|
||||||
|
import type { Ref } from "vue";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
export function profileNextRoute(courseType: string | string[]) {
|
||||||
|
if (courseType === "uk") {
|
||||||
|
return "setupComplete";
|
||||||
|
}
|
||||||
|
// vv- -> vv-de, vv-fr or vv-it
|
||||||
|
if (isString(courseType) && startsWith(courseType, "vv-")) {
|
||||||
|
return "checkoutAddress";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Organisation = {
|
||||||
|
organisation_id: number;
|
||||||
|
name_de: string;
|
||||||
|
name_fr: string;
|
||||||
|
name_it: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Country = {
|
||||||
|
country_id: number;
|
||||||
|
name_de: string;
|
||||||
|
name_fr: string;
|
||||||
|
name_it: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Entities = {
|
||||||
|
organisations: Organisation[];
|
||||||
|
countries: Country[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useEntities() {
|
||||||
|
const user = useUserStore();
|
||||||
|
const entities: Ref<Entities | undefined> = ref();
|
||||||
|
|
||||||
|
itGetCached("/api/core/entities/").then((res) => {
|
||||||
|
entities.value = res;
|
||||||
|
});
|
||||||
|
|
||||||
|
const organisations = computed(() => {
|
||||||
|
if (entities.value) {
|
||||||
|
return entities.value.organisations.map((c) => ({
|
||||||
|
id: c.organisation_id,
|
||||||
|
name: c[`name_${user.language}`],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const countries = computed(() => {
|
||||||
|
if (entities.value) {
|
||||||
|
return entities.value.countries.map((c) => ({
|
||||||
|
id: c.country_id,
|
||||||
|
name: c[`name_${user.language}`],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return { organisations, countries };
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ export type UserState = {
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
avatar_url: string;
|
avatar_url: string;
|
||||||
|
organisation: number | null;
|
||||||
is_superuser: boolean;
|
is_superuser: boolean;
|
||||||
course_session_experts: string[];
|
course_session_experts: string[];
|
||||||
loggedIn: boolean;
|
loggedIn: boolean;
|
||||||
|
|
@ -57,6 +58,7 @@ const initialUserState: UserState = {
|
||||||
username: "",
|
username: "",
|
||||||
avatar_url: "",
|
avatar_url: "",
|
||||||
is_superuser: false,
|
is_superuser: false,
|
||||||
|
organisation: 0,
|
||||||
course_session_experts: [],
|
course_session_experts: [],
|
||||||
loggedIn: false,
|
loggedIn: false,
|
||||||
language: defaultLanguage,
|
language: defaultLanguage,
|
||||||
|
|
@ -84,6 +86,19 @@ export const useUserStore = defineStore({
|
||||||
getFullName(): string {
|
getFullName(): string {
|
||||||
return `${this.first_name} ${this.last_name}`.trim();
|
return `${this.first_name} ${this.last_name}`.trim();
|
||||||
},
|
},
|
||||||
|
languageName(): string {
|
||||||
|
if (this.language === "de") {
|
||||||
|
return "Deutsch";
|
||||||
|
}
|
||||||
|
if (this.language === "fr") {
|
||||||
|
return "Français";
|
||||||
|
}
|
||||||
|
if (this.language === "it") {
|
||||||
|
return "Italiano";
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.language;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
handleLogin(username: string, password: string, next = "/") {
|
handleLogin(username: string, password: string, next = "/") {
|
||||||
|
|
@ -131,5 +146,9 @@ export const useUserStore = defineStore({
|
||||||
this.$state.language = language;
|
this.$state.language = language;
|
||||||
await itPost("/api/core/me/", { language }, { method: "PUT" });
|
await itPost("/api/core/me/", { language }, { method: "PUT" });
|
||||||
},
|
},
|
||||||
|
async setUserOrganisation(organisation: number) {
|
||||||
|
this.$state.organisation = organisation;
|
||||||
|
await itPost("/api/core/me/", { organisation }, { method: "PUT" });
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,27 @@ textarea {
|
||||||
.btn-large-icon {
|
.btn-large-icon {
|
||||||
@apply flex items-center px-6 py-3 text-xl font-bold;
|
@apply flex items-center px-6 py-3 text-xl font-bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.circle-numbered-list {
|
||||||
|
@apply my-8 list-outside list-decimal list-none pl-0;
|
||||||
|
counter-reset: list-counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-numbered-list li {
|
||||||
|
@apply relative mb-8 pl-10;
|
||||||
|
counter-increment: list-counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-numbered-list li:last-of-type {
|
||||||
|
@apply mb-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-numbered-list li::before {
|
||||||
|
content: counter(list-counter);
|
||||||
|
@apply absolute left-0 flex h-6 w-6 items-center justify-center rounded-full border border-gray-500 text-sm;
|
||||||
|
top: 1rem;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
|
||||||
|
|
@ -11,36 +11,36 @@ describe("login.cy.js", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can login to app with username/password", () => {
|
it("can login to app with username/password", () => {
|
||||||
cy.visit("/");
|
cy.visit("/login-local");
|
||||||
|
|
||||||
cy.get("#username").type("test-student1@example.com");
|
cy.get("#username").type("test-student1@example.com");
|
||||||
cy.get("#password").type("test");
|
cy.get("#password").type("test");
|
||||||
|
|
||||||
cy.get('[data-cy="login-button"]').click();
|
cy.get("[data-cy=\"login-button\"]").click();
|
||||||
cy.request("/api/core/me").its("status").should("eq", 200);
|
cy.request("/api/core/me").its("status").should("eq", 200);
|
||||||
|
|
||||||
cy.get('[data-cy="dashboard-title"]').should("contain", "Dashboard");
|
cy.get("[data-cy=\"dashboard-title\"]").should("contain", "Dashboard");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("can login with helper function", () => {
|
it("can login with helper function", () => {
|
||||||
login("test-student1@example.com", "test");
|
login("test-student1@example.com", "test");
|
||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
cy.request("/api/core/me").its("status").should("eq", 200);
|
cy.request("/api/core/me").its("status").should("eq", 200);
|
||||||
cy.get('[data-cy="dashboard-title"]').should("contain", "Dashboard");
|
cy.get("[data-cy=\"dashboard-title\"]").should("contain", "Dashboard");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("login will redirect to requestet page", () => {
|
it("login will redirect to requested page", () => {
|
||||||
cy.visit("/course/test-lehrgang/learn");
|
cy.visit("/course/test-lehrgang/learn");
|
||||||
cy.get("h1").should("contain", "Login");
|
cy.get("h1").should("contain", "Login");
|
||||||
|
|
||||||
cy.get("#username").type("test-student1@example.com");
|
cy.get("#username").type("test-student1@example.com");
|
||||||
cy.get("#password").type("test");
|
cy.get("#password").type("test");
|
||||||
|
|
||||||
cy.get('[data-cy="login-button"]').click();
|
cy.get("[data-cy=\"login-button\"]").click();
|
||||||
|
|
||||||
cy.get('[data-cy="learning-path-title"]').should(
|
cy.get("[data-cy=\"learning-path-title\"]").should(
|
||||||
"contain",
|
"contain",
|
||||||
"Test Lehrgang",
|
"Test Lehrgang"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ LOCAL_APPS = [
|
||||||
"vbv_lernwelt.importer",
|
"vbv_lernwelt.importer",
|
||||||
"vbv_lernwelt.edoniq_test",
|
"vbv_lernwelt.edoniq_test",
|
||||||
"vbv_lernwelt.course_session_group",
|
"vbv_lernwelt.course_session_group",
|
||||||
|
"vbv_lernwelt.shop",
|
||||||
"vbv_lernwelt.learning_mentor",
|
"vbv_lernwelt.learning_mentor",
|
||||||
]
|
]
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||||
|
|
@ -558,7 +559,12 @@ else:
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
||||||
|
|
||||||
ALLOWED_HOSTS = env.list(
|
ALLOWED_HOSTS = env.list(
|
||||||
"IT_DJANGO_ALLOWED_HOSTS", default=["localhost", "0.0.0.0", "127.0.0.1"]
|
"IT_DJANGO_ALLOWED_HOSTS",
|
||||||
|
default=[
|
||||||
|
"localhost",
|
||||||
|
"0.0.0.0",
|
||||||
|
"127.0.0.1",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# CACHES
|
# CACHES
|
||||||
|
|
@ -584,38 +590,45 @@ if "django_redis.cache.RedisCache" in env("IT_DJANGO_CACHE_BACKEND", default="")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# OAuth/OpenId Connect
|
# OAuth (SSO) settings
|
||||||
IT_OAUTH_TENANT_ID = env.str("IT_OAUTH_TENANT_ID", default=None)
|
OAUTH_SIGNUP_TENANT_ID = env("OAUTH_SIGNUP_TENANT_ID", default=None)
|
||||||
|
OAUTH_SIGNUP_PARAMS = (
|
||||||
|
{"tenant_id": OAUTH_SIGNUP_TENANT_ID} if OAUTH_SIGNUP_TENANT_ID else {}
|
||||||
|
)
|
||||||
|
|
||||||
if IT_OAUTH_TENANT_ID:
|
AUTHLIB_OAUTH_CLIENTS = {
|
||||||
IT_OAUTH_AUTHORIZE_PARAMS = {"tenant_id": IT_OAUTH_TENANT_ID}
|
"signup": {
|
||||||
else:
|
# azure
|
||||||
IT_OAUTH_AUTHORIZE_PARAMS = {}
|
"client_id": env("OAUTH_SIGNUP_CLIENT_ID", ""),
|
||||||
|
"client_secret": env("OAUTH_SIGNUP_CLIENT_SECRET", ""),
|
||||||
OAUTH = {
|
"server_metadata_url": env("OAUTH_SIGNUP_SERVER_METADATA_URL", ""),
|
||||||
"client_name": env("IT_OAUTH_CLIENT_NAME", default="lernetz"),
|
"access_token_params": OAUTH_SIGNUP_PARAMS,
|
||||||
"client_id": env("IT_OAUTH_CLIENT_ID", default="iterativ"),
|
"authorize_params": OAUTH_SIGNUP_PARAMS,
|
||||||
"client_secret": env("IT_OAUTH_CLIENT_SECRET", default=""),
|
"client_kwargs": {
|
||||||
"authorize_params": IT_OAUTH_AUTHORIZE_PARAMS,
|
"scope": "openid",
|
||||||
"access_token_params": IT_OAUTH_AUTHORIZE_PARAMS,
|
"token_endpoint_auth_method": "client_secret_post",
|
||||||
"api_base_url": env(
|
"token_placement": "body",
|
||||||
"IT_OAUTH_API_BASE_URL",
|
},
|
||||||
default="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/",
|
},
|
||||||
),
|
"signin": {
|
||||||
"local_redirect_uri": env(
|
# keycloak
|
||||||
"IT_OAUTH_LOCAL_REDIRECT_URI", default="http://localhost:8000/sso/callback/"
|
"client_id": env("OAUTH_SIGNIN_CLIENT_ID", ""),
|
||||||
),
|
"client_secret": env("OAUTH_SIGNIN_CLIENT_SECRET", ""),
|
||||||
"server_metadata_url": env(
|
"server_metadata_url": env("OAUTH_SIGNIN_SERVER_METADATA_URL", ""),
|
||||||
"IT_OAUTH_SERVER_METADATA_URL",
|
"client_kwargs": {
|
||||||
default="https://sso.test.b.lernetz.host/auth/realms/vbv/.well-known/openid-configuration",
|
"scope": "openid email profile",
|
||||||
),
|
},
|
||||||
"client_kwargs": {
|
|
||||||
"scope": env("IT_OAUTH_SCOPE", default="openid email"),
|
|
||||||
"token_endpoint_auth_method": "client_secret_post",
|
|
||||||
"token_placement": "body",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OAUTH_SIGNUP_REDIRECT_URI = env(
|
||||||
|
"OAUTH_SIGNUP_REDIRECT_URI", default="http://localhost:8000/sso/login"
|
||||||
|
)
|
||||||
|
|
||||||
|
OAUTH_SIGNIN_REDIRECT_URI = env(
|
||||||
|
"OAUTH_SIGNIN_REDIRECT_URI", default="http://localhost:8000/sso/callback"
|
||||||
|
)
|
||||||
|
|
||||||
GRAPHENE = {
|
GRAPHENE = {
|
||||||
"SCHEMA": "vbv_lernwelt.core.schema.schema",
|
"SCHEMA": "vbv_lernwelt.core.schema.schema",
|
||||||
"SCHEMA_OUTPUT": "../client/src/gql/schema.graphql",
|
"SCHEMA_OUTPUT": "../client/src/gql/schema.graphql",
|
||||||
|
|
@ -648,6 +661,26 @@ NOTIFICATIONS_NOTIFICATION_MODEL = "notify.Notification"
|
||||||
# sendgrid (email notifications)
|
# sendgrid (email notifications)
|
||||||
SENDGRID_API_KEY = env("IT_SENDGRID_API_KEY", default="")
|
SENDGRID_API_KEY = env("IT_SENDGRID_API_KEY", default="")
|
||||||
|
|
||||||
|
# Datatrans (payment)
|
||||||
|
# See https://admin.sandbox.datatrans.com/MerchSecurAdmin.jsp
|
||||||
|
DATATRANS_HMAC_KEY = env("DATATRANS_HMAC_KEY", default="")
|
||||||
|
|
||||||
|
# See https://admin.sandbox.datatrans.com/MenuDispatch.jsp?main=1&sub=4
|
||||||
|
# => echo -n "Username:Password" | base64
|
||||||
|
DATATRANS_BASIC_AUTH_KEY = env("DATATRANS_BASIC_AUTH_KEY", default="")
|
||||||
|
|
||||||
|
if APP_ENVIRONMENT.startswith("prod"):
|
||||||
|
DATATRANS_API_ENDPOINT = "https://api.datatrans.com"
|
||||||
|
DATATRANS_PAY_URL = "https://pay.datatrans.com"
|
||||||
|
else:
|
||||||
|
DATATRANS_API_ENDPOINT = "https://api.sandbox.datatrans.com"
|
||||||
|
DATATRANS_PAY_URL = "https://pay.sandbox.datatrans.com"
|
||||||
|
|
||||||
|
# Only for debugging the webhook (locally)
|
||||||
|
DATATRANS_DEBUG_WEBHOOK_OVERWRITE = env(
|
||||||
|
"DATATRANS_DEBUG_WEBHOOK_OVERWRITE", default=None
|
||||||
|
)
|
||||||
|
|
||||||
# S3 BUCKET CONFIGURATION
|
# S3 BUCKET CONFIGURATION
|
||||||
FILE_UPLOAD_STORAGE = env("FILE_UPLOAD_STORAGE", default="s3") # local | s3
|
FILE_UPLOAD_STORAGE = env("FILE_UPLOAD_STORAGE", default="s3") # local | s3
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ from django.views.decorators.csrf import csrf_exempt
|
||||||
from django_ratelimit.exceptions import Ratelimited
|
from django_ratelimit.exceptions import Ratelimited
|
||||||
from graphene_django.views import GraphQLView
|
from graphene_django.views import GraphQLView
|
||||||
|
|
||||||
|
from vbv_lernwelt.api.directory import list_entities
|
||||||
|
from vbv_lernwelt.api.user import me_user_view
|
||||||
from vbv_lernwelt.api.user import get_cockpit_type
|
from vbv_lernwelt.api.user import get_cockpit_type
|
||||||
from vbv_lernwelt.assignment.views import request_assignment_completion_status
|
from vbv_lernwelt.assignment.views import request_assignment_completion_status
|
||||||
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
|
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
|
||||||
|
|
@ -19,7 +21,6 @@ from vbv_lernwelt.core.views import (
|
||||||
check_rate_limit,
|
check_rate_limit,
|
||||||
cypress_reset_view,
|
cypress_reset_view,
|
||||||
generate_web_component_icons,
|
generate_web_component_icons,
|
||||||
me_user_view,
|
|
||||||
permission_denied_view,
|
permission_denied_view,
|
||||||
rate_limit_exceeded_view,
|
rate_limit_exceeded_view,
|
||||||
vue_home,
|
vue_home,
|
||||||
|
|
@ -99,6 +100,8 @@ urlpatterns = [
|
||||||
# user management
|
# user management
|
||||||
path("sso/", include("vbv_lernwelt.sso.urls")),
|
path("sso/", include("vbv_lernwelt.sso.urls")),
|
||||||
re_path(r'api/core/me/$', me_user_view, name='me_user_view'),
|
re_path(r'api/core/me/$', me_user_view, name='me_user_view'),
|
||||||
|
re_path(r'api/core/entities/$', list_entities, name='list_entities'),
|
||||||
|
|
||||||
re_path(r'api/core/login/$', django_view_authentication_exempt(vue_login),
|
re_path(r'api/core/login/$', django_view_authentication_exempt(vue_login),
|
||||||
name='vue_login'),
|
name='vue_login'),
|
||||||
re_path(r'api/core/logout/$', vue_logout, name='vue_logout'),
|
re_path(r'api/core/logout/$', vue_logout, name='vue_logout'),
|
||||||
|
|
@ -174,6 +177,9 @@ urlpatterns = [
|
||||||
path(r'api/core/edoniq-test/export-users-trainers/', export_students_and_trainers,
|
path(r'api/core/edoniq-test/export-users-trainers/', export_students_and_trainers,
|
||||||
name='edoniq_export_students_and_trainers'),
|
name='edoniq_export_students_and_trainers'),
|
||||||
|
|
||||||
|
# shop
|
||||||
|
path("api/shop/", include("vbv_lernwelt.shop.urls")),
|
||||||
|
|
||||||
# importer
|
# importer
|
||||||
path(
|
path(
|
||||||
r"server/importer/coursesession-trainer-import/",
|
r"server/importer/coursesession-trainer-import/",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.models import Organisation
|
||||||
|
from vbv_lernwelt.core.serializers import OrganisationSerializer
|
||||||
|
from vbv_lernwelt.shop.models import Country
|
||||||
|
from vbv_lernwelt.shop.serializers import CountrySerializer
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def list_entities(request):
|
||||||
|
organisations = OrganisationSerializer(Organisation.objects.all(), many=True).data
|
||||||
|
countries = CountrySerializer(Country.objects.all(), many=True).data
|
||||||
|
return Response({"organisations": organisations, "countries": countries})
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.model_utils import add_organisations
|
||||||
|
from vbv_lernwelt.core.models import User
|
||||||
|
from vbv_lernwelt.shop.model_utils import add_countries
|
||||||
|
|
||||||
|
|
||||||
|
class EntitiesViewTest(APITestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
"testuser", "test@example.com", "testpassword"
|
||||||
|
)
|
||||||
|
self.client.login(username="testuser", password="testpassword")
|
||||||
|
add_organisations()
|
||||||
|
add_countries()
|
||||||
|
|
||||||
|
def test_list_entities(self) -> None:
|
||||||
|
# GIVEN
|
||||||
|
url = reverse("list_entities")
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
organisations = response.data["organisations"]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
organisations[0],
|
||||||
|
{
|
||||||
|
"organisation_id": 1,
|
||||||
|
"name_de": "andere Broker",
|
||||||
|
"name_fr": "autres Broker",
|
||||||
|
"name_it": "altre Broker",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
countries = response.data["countries"]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
countries[0],
|
||||||
|
{
|
||||||
|
"country_id": 1,
|
||||||
|
"name_de": "Afghanistan",
|
||||||
|
"name_fr": "Afghanistan",
|
||||||
|
"name_it": "Afghanistan",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.model_utils import add_organisations
|
||||||
|
from vbv_lernwelt.core.models import User
|
||||||
|
from vbv_lernwelt.shop.model_utils import add_countries
|
||||||
|
|
||||||
|
|
||||||
|
class MeUserViewTest(APITestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
"testuser", "test@example.com", "testpassword"
|
||||||
|
)
|
||||||
|
self.client.login(username="testuser", password="testpassword")
|
||||||
|
add_organisations()
|
||||||
|
add_countries()
|
||||||
|
|
||||||
|
def test_user_can_update_language(self) -> None:
|
||||||
|
# GIVEN
|
||||||
|
url = reverse("me_user_view")
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.put(url, {"language": "it"})
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
updated_user = User.objects.get(username="testuser")
|
||||||
|
self.assertEquals(updated_user.language, "it")
|
||||||
|
|
||||||
|
def test_user_can_update_org(self) -> None:
|
||||||
|
# GIVEN
|
||||||
|
url = reverse("me_user_view") # replace with your actual URL name
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.put(url, {"organisation": 6})
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
updated_user = User.objects.get(username="testuser")
|
||||||
|
self.assertEquals(updated_user.organisation.organisation_id, 6)
|
||||||
|
|
@ -8,6 +8,29 @@ from vbv_lernwelt.course.models import Course, CourseSessionUser
|
||||||
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
|
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
|
||||||
from vbv_lernwelt.learning_mentor.models import LearningMentor
|
from vbv_lernwelt.learning_mentor.models import LearningMentor
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.serializers import UserSerializer
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET", "PUT"])
|
||||||
|
def me_user_view(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return Response(status=403)
|
||||||
|
|
||||||
|
if request.method == "GET":
|
||||||
|
return Response(UserSerializer(request.user).data)
|
||||||
|
|
||||||
|
if request.method == "PUT":
|
||||||
|
serializer = UserSerializer(
|
||||||
|
request.user,
|
||||||
|
data=request.data,
|
||||||
|
partial=True,
|
||||||
|
)
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(UserSerializer(request.user).data)
|
||||||
|
|
||||||
|
return Response(status=400)
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET"])
|
@api_view(["GET"])
|
||||||
@permission_classes([IsAuthenticated])
|
@permission_classes([IsAuthenticated])
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ 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 JobLog
|
from vbv_lernwelt.core.models import JobLog, Organisation
|
||||||
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()
|
||||||
|
|
@ -43,6 +43,7 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
|
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
|
||||||
|
(_("Profile"), {"fields": ("organisation", "language")}),
|
||||||
(_("Additional data"), {"fields": ("additional_json_data",)}),
|
(_("Additional data"), {"fields": ("additional_json_data",)}),
|
||||||
)
|
)
|
||||||
list_display = [
|
list_display = [
|
||||||
|
|
@ -78,3 +79,13 @@ class JobLogAdmin(LogAdmin):
|
||||||
if obj.ended:
|
if obj.ended:
|
||||||
return (obj.ended - obj.started).seconds // 60
|
return (obj.ended - obj.started).seconds // 60
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Organisation)
|
||||||
|
class OrganisationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"organisation_id",
|
||||||
|
"name_de",
|
||||||
|
"name_fr",
|
||||||
|
"name_it",
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.model_utils import add_organisations, remove_organisations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("core", "0002_joblog"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Organisation",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"organisation_id",
|
||||||
|
models.IntegerField(primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
("name_de", models.CharField(max_length=255)),
|
||||||
|
("name_fr", models.CharField(max_length=255)),
|
||||||
|
("name_it", models.CharField(max_length=255)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Organisation",
|
||||||
|
"verbose_name_plural": "Organisations",
|
||||||
|
"ordering": ["organisation_id"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="organisation",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to="core.organisation",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(add_organisations, remove_organisations),
|
||||||
|
]
|
||||||
|
|
@ -32,3 +32,115 @@ def find_available_slug(requested_slug, ignore_page_id=None):
|
||||||
number += 1
|
number += 1
|
||||||
|
|
||||||
return slug
|
return slug
|
||||||
|
|
||||||
|
|
||||||
|
orgs = {
|
||||||
|
1: {"de": "andere Broker", "fr": "autres Broker", "it": "altre Broker"},
|
||||||
|
2: {
|
||||||
|
"de": "andere Krankenversicherer",
|
||||||
|
"fr": "autres assureurs santé",
|
||||||
|
"it": "altre assicurazioni sanitarie",
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
"de": "andere Privatversicherer",
|
||||||
|
"fr": "autres Assurance privée",
|
||||||
|
"it": "altre Assicurazione privato",
|
||||||
|
},
|
||||||
|
4: {"de": "Allianz Suisse", "fr": "Allianz Suisse", "it": "Allianz Suisse"},
|
||||||
|
5: {"de": "AON", "fr": "AON", "it": "AON"},
|
||||||
|
6: {
|
||||||
|
"de": "AXA Winterthur",
|
||||||
|
"fr": "AXA Assurances SA",
|
||||||
|
"it": "AXA Assicurazioni SA",
|
||||||
|
},
|
||||||
|
7: {"de": "Baloise", "fr": "Baloise", "it": "Baloise"},
|
||||||
|
8: {
|
||||||
|
"de": "CAP Rechtsschutz",
|
||||||
|
"fr": "CAP Protection juridique",
|
||||||
|
"it": "CAP Protezione giuridica",
|
||||||
|
},
|
||||||
|
9: {
|
||||||
|
"de": "Coop Rechtsschutz",
|
||||||
|
"fr": "Coop Protection juridique",
|
||||||
|
"it": "Coop Protezione giuridica",
|
||||||
|
},
|
||||||
|
10: {"de": "CSS", "fr": "CSS", "it": "CSS"},
|
||||||
|
11: {"de": "Die Mobiliar", "fr": "La Mobilière", "it": "La Mobiliare"},
|
||||||
|
12: {
|
||||||
|
"de": "Emmental Versicherung",
|
||||||
|
"fr": "Emmental Assurance",
|
||||||
|
"it": "Emmental Assicurazione",
|
||||||
|
},
|
||||||
|
13: {
|
||||||
|
"de": "GENERALI Versicherungen",
|
||||||
|
"fr": "Generali Assurances",
|
||||||
|
"it": "Generali Assicurazioni",
|
||||||
|
},
|
||||||
|
14: {"de": "Groupe Mutuel", "fr": "GROUPE MUTUEL", "it": "GROUPE MUTUEL"},
|
||||||
|
15: {"de": "Helsana", "fr": "Helsana", "it": "Helsana"},
|
||||||
|
16: {"de": "Helvetia", "fr": "Helvetia", "it": "Helvetia"},
|
||||||
|
17: {"de": "Kessler & Co AG", "fr": "Kessler & Co AG", "it": "Kessler & Co AG"},
|
||||||
|
18: {
|
||||||
|
"de": "Orion Rechtsschutz Versicherung",
|
||||||
|
"fr": "Orion Protection juridique",
|
||||||
|
"it": "Orion Protezione giuridica",
|
||||||
|
},
|
||||||
|
19: {"de": "PAX", "fr": "PAX", "it": "PAX"},
|
||||||
|
20: {"de": "Sanitas", "fr": "Sanitas", "it": "Sanitas"},
|
||||||
|
21: {"de": "SUVA", "fr": "SUVA", "it": "SUVA"},
|
||||||
|
22: {"de": "Swica", "fr": "Swica", "it": "Swica"},
|
||||||
|
23: {"de": "Swiss Life", "fr": "Swiss Life", "it": "Swiss Life"},
|
||||||
|
24: {"de": "Swiss Re", "fr": "Swiss Re", "it": "Swiss Re"},
|
||||||
|
25: {
|
||||||
|
"de": "Visana Services AG",
|
||||||
|
"fr": "Visana Services SA",
|
||||||
|
"it": "Visana Services SA",
|
||||||
|
},
|
||||||
|
26: {
|
||||||
|
"de": "VZ VermögensZentrum AG",
|
||||||
|
"fr": "VZ VermögensZentrum AG",
|
||||||
|
"it": "VZ VermögensZentrum AG",
|
||||||
|
},
|
||||||
|
27: {
|
||||||
|
"de": "Würth Financial Services AG",
|
||||||
|
"fr": "Würth Financial Services SA",
|
||||||
|
"it": "Würth Financial Services SA",
|
||||||
|
},
|
||||||
|
28: {"de": "Zürich", "fr": "Zurich", "it": "Zurigo"},
|
||||||
|
29: {"de": "VBV", "fr": "AFA", "it": "AFA"},
|
||||||
|
30: {"de": "Vaudoise", "fr": "Vaudoise", "it": "Vaudoise"},
|
||||||
|
31: {
|
||||||
|
"de": "Keine Firmenzugehörigkeit",
|
||||||
|
"fr": "Pas d'appartenance à une entreprise",
|
||||||
|
"it": "Nessuna affiliazione aziendale",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_organisations(apps=None, schema_editor=None):
|
||||||
|
if apps is None:
|
||||||
|
# pylint: disable=import-outside-toplevel
|
||||||
|
from vbv_lernwelt.core.models import Organisation
|
||||||
|
else:
|
||||||
|
Organisation = apps.get_model("core", "Organisation")
|
||||||
|
|
||||||
|
for org_id, org_data in orgs.items():
|
||||||
|
Organisation.objects.get_or_create(
|
||||||
|
organisation_id=org_id,
|
||||||
|
name_de=org_data["de"],
|
||||||
|
name_fr=org_data["fr"],
|
||||||
|
name_it=org_data["it"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_organisations(apps=None, schema_editor=None):
|
||||||
|
if apps is None:
|
||||||
|
# pylint: disable=import-outside-toplevel
|
||||||
|
from vbv_lernwelt.core.models import Organisation
|
||||||
|
else:
|
||||||
|
Organisation = apps.get_model("core", "Organisation")
|
||||||
|
|
||||||
|
for org_id in orgs.keys():
|
||||||
|
Organisation.objects.filter(
|
||||||
|
organisation_id=org_id,
|
||||||
|
).delete()
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,21 @@ from django.db import models
|
||||||
from django.db.models import JSONField
|
from django.db.models import JSONField
|
||||||
|
|
||||||
|
|
||||||
|
class Organisation(models.Model):
|
||||||
|
organisation_id = models.IntegerField(primary_key=True)
|
||||||
|
name_de = models.CharField(max_length=255)
|
||||||
|
name_fr = models.CharField(max_length=255)
|
||||||
|
name_it = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name_de} ({self.organisation_id})"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Organisation"
|
||||||
|
verbose_name_plural = "Organisations"
|
||||||
|
ordering = ["organisation_id"]
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
"""
|
"""
|
||||||
Default custom user model for VBV Lernwelt.
|
Default custom user model for VBV Lernwelt.
|
||||||
|
|
@ -29,6 +44,10 @@ class User(AbstractUser):
|
||||||
additional_json_data = JSONField(default=dict, blank=True)
|
additional_json_data = JSONField(default=dict, blank=True)
|
||||||
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="de")
|
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="de")
|
||||||
|
|
||||||
|
organisation = models.ForeignKey(
|
||||||
|
Organisation, on_delete=models.SET_NULL, null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SecurityRequestResponseLog(models.Model):
|
class SecurityRequestResponseLog(models.Model):
|
||||||
label = models.CharField(max_length=255, blank=True, default="")
|
label = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ 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 User
|
from vbv_lernwelt.core.models import Organisation, 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
|
||||||
|
|
||||||
|
|
@ -25,6 +25,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||||
"email",
|
"email",
|
||||||
"username",
|
"username",
|
||||||
"avatar_url",
|
"avatar_url",
|
||||||
|
"organisation",
|
||||||
"is_superuser",
|
"is_superuser",
|
||||||
"course_session_experts",
|
"course_session_experts",
|
||||||
"language",
|
"language",
|
||||||
|
|
@ -52,3 +53,9 @@ class UserSerializer(serializers.ModelSerializer):
|
||||||
)
|
)
|
||||||
|
|
||||||
return [str(_id) for _id in (supervisor_in_session_ids | expert_in_session_ids)]
|
return [str(_id) for _id in (supervisor_in_session_ids | expert_in_session_ids)]
|
||||||
|
|
||||||
|
|
||||||
|
class OrganisationSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Organisation
|
||||||
|
fields = "__all__"
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,12 @@ import structlog
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import authenticate, login, logout
|
from django.contrib.auth import authenticate, login, logout
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
|
from django.http import (
|
||||||
|
HttpResponse,
|
||||||
|
HttpResponseRedirect,
|
||||||
|
JsonResponse,
|
||||||
|
StreamingHttpResponse,
|
||||||
|
)
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.template import loader
|
from django.template import loader
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
|
|
@ -31,12 +36,21 @@ logger = structlog.get_logger(__name__)
|
||||||
@ensure_csrf_cookie
|
@ensure_csrf_cookie
|
||||||
def vue_home(request, *args):
|
def vue_home(request, *args):
|
||||||
if settings.IT_SERVE_VUE:
|
if settings.IT_SERVE_VUE:
|
||||||
|
from gunicorn.util import is_hoppish
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res = requests.get(f"{settings.IT_SERVE_VUE_URL}{request.get_full_path()}")
|
path = request.get_full_path()
|
||||||
content = res.text
|
res = requests.get(f"{settings.IT_SERVE_VUE_URL}{path}", stream=True)
|
||||||
headers = res.headers
|
response = StreamingHttpResponse(
|
||||||
content_type = headers.get("content-type", "text/html")
|
streaming_content=(chunk for chunk in res.iter_content(4096)),
|
||||||
return HttpResponse(content, content_type=content_type)
|
content_type=res.headers.get("Content-Type", "text/html"),
|
||||||
|
status=res.status_code,
|
||||||
|
)
|
||||||
|
for name, value in res.headers.items():
|
||||||
|
if not is_hoppish(name):
|
||||||
|
response[name] = value
|
||||||
|
|
||||||
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
f"Can not connect to vue dev server at {settings.IT_SERVE_VUE_URL}: {e}"
|
f"Can not connect to vue dev server at {settings.IT_SERVE_VUE_URL}: {e}"
|
||||||
|
|
@ -76,27 +90,6 @@ def vue_login(request):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@api_view(["GET", "PUT"])
|
|
||||||
def me_user_view(request):
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
return Response(status=403)
|
|
||||||
|
|
||||||
if request.method == "GET":
|
|
||||||
return Response(UserSerializer(request.user).data)
|
|
||||||
|
|
||||||
if request.method == "PUT":
|
|
||||||
serializer = UserSerializer(
|
|
||||||
request.user,
|
|
||||||
data={"language": request.data.get("language", "de")},
|
|
||||||
partial=True,
|
|
||||||
)
|
|
||||||
if serializer.is_valid():
|
|
||||||
serializer.save()
|
|
||||||
return Response(UserSerializer(request.user).data)
|
|
||||||
|
|
||||||
return Response(status=400)
|
|
||||||
|
|
||||||
|
|
||||||
@api_view(["POST"])
|
@api_view(["POST"])
|
||||||
def vue_logout(request):
|
def vue_logout(request):
|
||||||
logout(request)
|
logout(request)
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,8 @@ from vbv_lernwelt.learnpath.tests.learning_path_factories import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_course(title: str) -> Tuple[Course, CoursePage]:
|
def create_course(title: str, _id=None) -> Tuple[Course, CoursePage]:
|
||||||
course = Course.objects.create(title=title, category_name="Handlungsfeld")
|
course = Course.objects.create(id=_id, title=title, category_name="Handlungsfeld")
|
||||||
|
|
||||||
course_page = CoursePageFactory(
|
course_page = CoursePageFactory(
|
||||||
title="Test Lehrgang",
|
title="Test Lehrgang",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
from vbv_lernwelt.core.models import User
|
||||||
|
from vbv_lernwelt.course.consts import (
|
||||||
|
COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID,
|
||||||
|
COURSE_VERSICHERUNGSVERMITTLERIN_ID,
|
||||||
|
COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID,
|
||||||
|
)
|
||||||
|
from vbv_lernwelt.course.models import CourseSession
|
||||||
|
|
||||||
|
|
||||||
|
def has_course_session_user_vv(user: User) -> bool:
|
||||||
|
vv_course_ids = [
|
||||||
|
COURSE_VERSICHERUNGSVERMITTLERIN_ID,
|
||||||
|
COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID,
|
||||||
|
COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID,
|
||||||
|
]
|
||||||
|
|
||||||
|
return CourseSession.objects.filter(
|
||||||
|
course__id__in=vv_course_ids, coursesessionuser__user=user
|
||||||
|
).exists()
|
||||||
|
|
@ -216,7 +216,7 @@ def create_or_update_user(
|
||||||
sso_id: str = None,
|
sso_id: str = None,
|
||||||
contract_number: str = "",
|
contract_number: str = "",
|
||||||
date_of_birth: str = "",
|
date_of_birth: str = "",
|
||||||
):
|
) -> User:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"create_or_update_user",
|
"create_or_update_user",
|
||||||
email=email,
|
email=email,
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,13 @@ class EmailTemplate(Enum):
|
||||||
# VBV - Neues Feedback für Circle
|
# VBV - Neues Feedback für Circle
|
||||||
NEW_FEEDBACK = {"de": "d-40fb94d5149949e7b8e7ddfcf0fcfdde"}
|
NEW_FEEDBACK = {"de": "d-40fb94d5149949e7b8e7ddfcf0fcfdde"}
|
||||||
|
|
||||||
|
# Versicherungsvermittler (after buying a course)
|
||||||
|
WELCOME_MAIL_VV = {
|
||||||
|
"de": "d-308a72c779b74c8487cdec03c772ad13",
|
||||||
|
"fr": "d-1a0958c7798c4dd18f730491e920eab5",
|
||||||
|
"it": "d-0882ec9c92f64312b9f358481a943c9a",
|
||||||
|
}
|
||||||
|
|
||||||
# VBV - Lernbegleitung Einladung
|
# VBV - Lernbegleitung Einladung
|
||||||
LEARNING_MENTOR_INVITATION = {
|
LEARNING_MENTOR_INVITATION = {
|
||||||
"de": "d-8c862afde62748b6b8410887eeee89d8",
|
"de": "d-8c862afde62748b6b8410887eeee89d8",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
# Setup steps for Production
|
||||||
|
|
||||||
|
## Shop Product
|
||||||
|
|
||||||
|
In the Django shop app, create new products that should be available in the shop:
|
||||||
|
|
||||||
|
- `vv-de` Price 32430 (324_3- -> 324.30 CHF), base 300 CHF + 8.1% MWSt., name & description can be anything.
|
||||||
|
- ONLY if `COURSE_VERSICHERUNGSVERMITTLERIN_ID` exists!
|
||||||
|
- `vv-fr` Price 32430 (324_3- -> 324.30 CHF), base 300 CHF + 8.1% MWSt., name & description can be anything.
|
||||||
|
- ONLY if `COURSE_VERSICHERUNGSVERMITTLERIN_ID_FR` exists!
|
||||||
|
- `vv-it` Price 32430 (324_3- -> 324.30 CHF), base 300 CHF + 8.1% MWSt., name & description can be anything.
|
||||||
|
- ONLY if `COURSE_VERSICHERUNGSVERMITTLERIN_ID_IT` exists!
|
||||||
|
|
||||||
|
## Datatrans (Payment Provider)
|
||||||
|
|
||||||
|
- Set `DATATRANS_BASIC_AUTH_KEY`:
|
||||||
|
- https://admin.sandbox.datatrans.com/MenuDispatch.jsp?main=1&sub=4
|
||||||
|
- `echo -n "{merchantid}:{password}" | base64`
|
||||||
|
|
||||||
|
- Set `DATATRANS_HMAC_KEY`:
|
||||||
|
- https://admin.sandbox.datatrans.com/MerchSecurAdmin.jsp
|
||||||
|
|
||||||
|
For Production:
|
||||||
|
|
||||||
|
1. Coordinate with datatrans to get production account. -> TBD!
|
||||||
|
2. Set `DATATRANS_BASIC_AUTH_KEY` and `DATATRANS_HMAC_KEY` to the production values (see above).
|
||||||
|
|
||||||
|
## OAUTH
|
||||||
|
|
||||||
|
For Production: Make sure that the following env vars are set:
|
||||||
|
|
||||||
|
### Azure B2C
|
||||||
|
|
||||||
|
- Set `OAUTH_SIGNUP_CLIENT_ID`
|
||||||
|
- Set `OAUTH_SIGNUP_CLIENT_SECRET`
|
||||||
|
- Set `OAUTH_SIGNUP_SERVER_METADATA_URL` (.well-known/openid-configuration)
|
||||||
|
- Set `OAUTH_SIGNUP_TENANT_ID`
|
||||||
|
- Set `OAUTH_SIGNUP_REDIRECT_URI` (`.../sso/login` e.g. `https://myvbv-stage.iterativ.ch/sso/login`)
|
||||||
|
|
||||||
|
### Keycloak
|
||||||
|
|
||||||
|
- Set `OAUTH_SIGNIN_CLIENT_ID`
|
||||||
|
- Set `OAUTH_SIGNIN_CLIENT_SECRET`
|
||||||
|
- Set `OAUTH_SIGNIN_SERVER_METADATA_URL` (.well-known/openid-configuration)
|
||||||
|
- Set `OAUTH_SIGNIN_REDIRECT_URI` (`.../sso/callback` e.g. `https://myvbv-stage.iterativ.ch/sso/callback`)
|
||||||
|
|
||||||
|
### Caprover (VITEx)
|
||||||
|
|
||||||
|
- Set `VITE_OAUTH_API_BASE_URL` in `caprover_deploy.sh` for `prod` environment.
|
||||||
|
- `OAUTH_SIGNIN_SERVER_METADATA_URL` should help to find the correct value.
|
||||||
|
- Should be the SSO Prod one from Lernnetz. -> TBD!
|
||||||
|
|
||||||
|
### send_vv_welcome_email()
|
||||||
|
|
||||||
|
- Due to lack of access to Sendgrid, never tested actually sending the email.
|
||||||
|
|
||||||
|
## Testing Payment Flow
|
||||||
|
|
||||||
|
- To get user into state for testing (e.g. test-student1@example.com so that he can buy the course):
|
||||||
|
- Remove all existing course session users for the user.
|
||||||
|
- Remove all existing checkout information for the user.
|
||||||
|
|
||||||
|
### Cleanup
|
||||||
|
|
||||||
|
After everything runs fine, we should be able to remove the following deprecated env vars:
|
||||||
|
|
||||||
|
1. `IT_OAUTH_TENANT_ID`
|
||||||
|
2. `IT_OAUTH_CLIENT_NAME`
|
||||||
|
3. `IT_OAUTH_CLIENT_ID`
|
||||||
|
4. `IT_OAUTH_CLIENT_SECRET`
|
||||||
|
5. `IT_OAUTH_API_BASE_URL`
|
||||||
|
6. `IT_OAUTH_LOCAL_REDIRECT_URI`
|
||||||
|
7. `IT_OAUTH_SERVER_METADATA_URL`
|
||||||
|
8. `IT_OAUTH_SCOPE`
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from vbv_lernwelt.shop.models import CheckoutInformation, Country, Product
|
||||||
|
from vbv_lernwelt.shop.services import get_transaction_state
|
||||||
|
|
||||||
|
|
||||||
|
@admin.action(description="ABACUS: Create invoices")
|
||||||
|
def generate_invoice(modeladmin, request, queryset):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@admin.action(description="DATATRANS: Sync transaction states")
|
||||||
|
def sync_transaction_state(modeladmin, request, queryset):
|
||||||
|
for checkout in queryset:
|
||||||
|
state = get_transaction_state(transaction_id=checkout.transaction_id)
|
||||||
|
checkout.state = state.value
|
||||||
|
checkout.save(
|
||||||
|
update_fields=[
|
||||||
|
"state",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CheckoutInformation)
|
||||||
|
class CheckoutInformationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"product_sku",
|
||||||
|
"user",
|
||||||
|
"product_name",
|
||||||
|
"product_price",
|
||||||
|
"updated_at",
|
||||||
|
"state",
|
||||||
|
"invoice_transmitted_at",
|
||||||
|
)
|
||||||
|
actions = [generate_invoice, sync_transaction_state]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Country)
|
||||||
|
class CountryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"country_id",
|
||||||
|
"name_de",
|
||||||
|
"name_fr",
|
||||||
|
"name_it",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Product)
|
||||||
|
class ProductAdmin(admin.ModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"sku",
|
||||||
|
"name",
|
||||||
|
"price",
|
||||||
|
"description",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ShopConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "vbv_lernwelt.shop"
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
# available products for VV
|
||||||
|
VV_DE_PRODUCT_SKU = "vv-de"
|
||||||
|
VV_FR_PRODUCT_SKU = "vv-fr"
|
||||||
|
VV_IT_PRODUCT_SKU = "vv-it"
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import datetime
|
||||||
|
from typing import List
|
||||||
|
from uuid import uuid4
|
||||||
|
from xml.dom import minidom
|
||||||
|
from xml.etree.ElementTree import Element, SubElement, tostring
|
||||||
|
|
||||||
|
from vbv_lernwelt.shop.invoice.creator import InvoiceCreator, Item
|
||||||
|
from vbv_lernwelt.shop.invoice.repositories import InvoiceRepository
|
||||||
|
from vbv_lernwelt.shop.models import CheckoutInformation
|
||||||
|
|
||||||
|
|
||||||
|
class AbacusInvoiceCreator(InvoiceCreator):
|
||||||
|
def __init__(self, repository: InvoiceRepository):
|
||||||
|
self.repository = repository
|
||||||
|
|
||||||
|
def create_invoice(
|
||||||
|
self,
|
||||||
|
checkout_information: CheckoutInformation,
|
||||||
|
filename: str = None,
|
||||||
|
):
|
||||||
|
customer_number = checkout_information.transaction_id
|
||||||
|
order_date = checkout_information.created_at.date()
|
||||||
|
reference_purchase_order = str(checkout_information.id)
|
||||||
|
unic_id = checkout_information.transaction_id
|
||||||
|
|
||||||
|
items = [
|
||||||
|
Item(
|
||||||
|
product_number=checkout_information.product_sku,
|
||||||
|
quantity=1,
|
||||||
|
description=checkout_information.product_description,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
invoice = self.invoice_xml(
|
||||||
|
customer_number,
|
||||||
|
order_date,
|
||||||
|
reference_purchase_order,
|
||||||
|
unic_id,
|
||||||
|
items,
|
||||||
|
)
|
||||||
|
|
||||||
|
if filename is None:
|
||||||
|
filename = f"vbv-vv-{uuid4().hex}.xml"
|
||||||
|
|
||||||
|
self.repository.upload_invoice(invoice, filename)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def invoice_xml(
|
||||||
|
customer_number: str,
|
||||||
|
order_date: datetime.date,
|
||||||
|
reference_purchase_order: str,
|
||||||
|
unic_id: str,
|
||||||
|
items: List[Item],
|
||||||
|
) -> str:
|
||||||
|
container = Element("AbaConnectContainer")
|
||||||
|
task = SubElement(container, "Task")
|
||||||
|
parameter = SubElement(task, "Parameter")
|
||||||
|
SubElement(parameter, "Application").text = "ORDE"
|
||||||
|
SubElement(parameter, "Id").text = "Verkaufsauftrag"
|
||||||
|
SubElement(parameter, "MapId").text = "AbaDefault"
|
||||||
|
SubElement(parameter, "Version").text = "2022.00"
|
||||||
|
|
||||||
|
transaction = SubElement(task, "Transaction")
|
||||||
|
sales_order_header = SubElement(transaction, "SalesOrderHeader", mode="SAVE")
|
||||||
|
sales_order_header_fields = SubElement(
|
||||||
|
sales_order_header, "SalesOrderHeaderFields", mode="SAVE"
|
||||||
|
)
|
||||||
|
|
||||||
|
SubElement(sales_order_header_fields, "CustomerNumber").text = customer_number
|
||||||
|
SubElement(
|
||||||
|
sales_order_header_fields, "PurchaseOrderDate"
|
||||||
|
).text = order_date.isoformat()
|
||||||
|
SubElement(
|
||||||
|
sales_order_header_fields, "DeliveryDate"
|
||||||
|
).text = order_date.isoformat()
|
||||||
|
SubElement(
|
||||||
|
sales_order_header_fields, "ReferencePurchaseOrder"
|
||||||
|
).text = reference_purchase_order
|
||||||
|
SubElement(sales_order_header_fields, "UnicId").text = unic_id
|
||||||
|
|
||||||
|
for index, item in enumerate(items, start=1):
|
||||||
|
item_element = SubElement(sales_order_header, "Item", mode="SAVE")
|
||||||
|
item_fields = SubElement(item_element, "ItemFields", mode="SAVE")
|
||||||
|
SubElement(item_fields, "ItemNumber").text = str(index)
|
||||||
|
SubElement(item_fields, "ProductNumber").text = item.product_number
|
||||||
|
SubElement(item_fields, "QuantityOrdered").text = str(item.quantity)
|
||||||
|
|
||||||
|
item_text = SubElement(item_element, "ItemText", mode="SAVE")
|
||||||
|
item_text_fields = SubElement(item_text, "ItemTextFields", mode="SAVE")
|
||||||
|
SubElement(item_text_fields, "Text").text = item.description
|
||||||
|
|
||||||
|
return AbacusInvoiceCreator.create_xml_string(container)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def customer_xml(
|
||||||
|
customer_number: str,
|
||||||
|
name: str,
|
||||||
|
first_name: str,
|
||||||
|
address_text: str,
|
||||||
|
street: str,
|
||||||
|
house_number: str,
|
||||||
|
zip_code: str,
|
||||||
|
city: str,
|
||||||
|
country: str,
|
||||||
|
language: str,
|
||||||
|
email: str,
|
||||||
|
):
|
||||||
|
container = Element("AbaConnectContainer")
|
||||||
|
task = SubElement(container, "Task")
|
||||||
|
|
||||||
|
parameter = SubElement(task, "Parameter")
|
||||||
|
SubElement(parameter, "Application").text = "DEBI"
|
||||||
|
SubElement(parameter, "ID").text = "Kunden"
|
||||||
|
SubElement(parameter, "MapID").text = "AbaDefault"
|
||||||
|
SubElement(parameter, "Version").text = "2022.00"
|
||||||
|
|
||||||
|
transaction = SubElement(task, "Transaction")
|
||||||
|
customer_element = SubElement(transaction, "Customer", mode="SAVE")
|
||||||
|
|
||||||
|
SubElement(customer_element, "CustomerNumber").text = customer_number
|
||||||
|
SubElement(customer_element, "DefaultCurrency").text = "CHF"
|
||||||
|
SubElement(customer_element, "PaymentTermNumber").text = "1"
|
||||||
|
SubElement(customer_element, "ReminderProcedure").text = "NORM"
|
||||||
|
|
||||||
|
address_data = SubElement(customer_element, "AddressData", mode="SAVE")
|
||||||
|
SubElement(address_data, "AddressNumber").text = customer_number
|
||||||
|
SubElement(address_data, "Name").text = name
|
||||||
|
SubElement(address_data, "FirstName").text = first_name
|
||||||
|
SubElement(address_data, "Text").text = address_text
|
||||||
|
SubElement(address_data, "Street").text = street
|
||||||
|
SubElement(address_data, "HouseNumber").text = house_number
|
||||||
|
SubElement(address_data, "ZIP").text = zip_code
|
||||||
|
SubElement(address_data, "City").text = city
|
||||||
|
SubElement(address_data, "Country").text = country
|
||||||
|
SubElement(address_data, "Language").text = language
|
||||||
|
SubElement(address_data, "Email").text = email
|
||||||
|
|
||||||
|
return AbacusInvoiceCreator.create_xml_string(container)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_xml_string(container: Element, encoding: str = "UTF-8") -> str:
|
||||||
|
xml_bytes = tostring(container, encoding)
|
||||||
|
xml_pretty_str = minidom.parseString(xml_bytes).toprettyxml(
|
||||||
|
indent=" ", encoding=encoding
|
||||||
|
)
|
||||||
|
return xml_pretty_str.decode(encoding)
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from vbv_lernwelt.shop.models import CheckoutInformation
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Item:
|
||||||
|
product_number: str
|
||||||
|
quantity: int
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceCreator(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def create_invoice(
|
||||||
|
self,
|
||||||
|
checkout_information: CheckoutInformation,
|
||||||
|
filename: str = None,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
def upload_invoice(self, invoice: str, filename: str):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SFTPInvoiceRepository(InvoiceRepository):
|
||||||
|
def __init__(self, hostname: str, username: str, password: str, port: int = 22):
|
||||||
|
self.hostname = hostname
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.port = port
|
||||||
|
|
||||||
|
def upload_invoice(self, invoice: str, filename: str) -> None:
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from paramiko import AutoAddPolicy, SSHClient
|
||||||
|
|
||||||
|
invoice_io = BytesIO(invoice.encode("utf-8"))
|
||||||
|
ssh_client = SSHClient()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ssh_client.set_missing_host_key_policy(AutoAddPolicy())
|
||||||
|
ssh_client.connect(
|
||||||
|
self.hostname,
|
||||||
|
port=self.port,
|
||||||
|
username=self.username,
|
||||||
|
password=self.password,
|
||||||
|
)
|
||||||
|
|
||||||
|
with ssh_client.open_sftp() as sftp_client:
|
||||||
|
sftp_client.putfo(invoice_io, f"uploads/{filename}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Could not upload invoice", exc_info=e)
|
||||||
|
finally:
|
||||||
|
ssh_client.close()
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-11-14 10:46
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Product",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("price", models.IntegerField()),
|
||||||
|
(
|
||||||
|
"sku",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("1", "VV")], max_length=255, unique=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=255)),
|
||||||
|
("description", models.CharField(max_length=255)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CheckoutInformation",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"product_sku",
|
||||||
|
models.CharField(choices=[("1", "VV")], max_length=255),
|
||||||
|
),
|
||||||
|
("product_price", models.IntegerField()),
|
||||||
|
("product_name", models.CharField(max_length=255)),
|
||||||
|
("product_description", models.CharField(max_length=255)),
|
||||||
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_at", models.DateTimeField(auto_now=True)),
|
||||||
|
(
|
||||||
|
"state",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("initialized", "initialized"),
|
||||||
|
("settled", "settled"),
|
||||||
|
("canceled", "canceled"),
|
||||||
|
("failed", "failed"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("invoice_transmitted_at", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("transaction_id", models.CharField(max_length=255)),
|
||||||
|
("first_name", models.CharField(max_length=255)),
|
||||||
|
("last_name", models.CharField(max_length=255)),
|
||||||
|
("street_address", models.CharField(max_length=255)),
|
||||||
|
("street_number_address", models.CharField(max_length=255)),
|
||||||
|
("postal_code", models.CharField(max_length=255)),
|
||||||
|
("city", models.CharField(max_length=255)),
|
||||||
|
("country", models.CharField(max_length=255)),
|
||||||
|
("company_name", models.CharField(blank=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"company_street_address",
|
||||||
|
models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"company_street_number_address",
|
||||||
|
models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
("company_postal_code", models.CharField(blank=True, max_length=255)),
|
||||||
|
("company_city", models.CharField(blank=True, max_length=255)),
|
||||||
|
("company_country", models.CharField(blank=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.PROTECT,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BillingAddress",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("first_name", models.CharField(blank=True, max_length=255)),
|
||||||
|
("last_name", models.CharField(blank=True, max_length=255)),
|
||||||
|
("street_address", models.CharField(blank=True, max_length=255)),
|
||||||
|
("street_number_address", models.CharField(blank=True, max_length=255)),
|
||||||
|
("postal_code", models.CharField(blank=True, max_length=255)),
|
||||||
|
("city", models.CharField(blank=True, max_length=255)),
|
||||||
|
("country", models.CharField(blank=True, max_length=255)),
|
||||||
|
("company_name", models.CharField(blank=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"company_street_address",
|
||||||
|
models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"company_street_number_address",
|
||||||
|
models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
("company_postal_code", models.CharField(blank=True, max_length=255)),
|
||||||
|
("company_city", models.CharField(blank=True, max_length=255)),
|
||||||
|
("company_country", models.CharField(blank=True, max_length=255)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-11-14 18:26
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("shop", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="billingaddress",
|
||||||
|
old_name="company_street_address",
|
||||||
|
new_name="company_street",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="billingaddress",
|
||||||
|
old_name="company_street_number_address",
|
||||||
|
new_name="company_street_number",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="billingaddress",
|
||||||
|
old_name="street_address",
|
||||||
|
new_name="street",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="billingaddress",
|
||||||
|
old_name="street_number_address",
|
||||||
|
new_name="street_number",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-11-14 19:36
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
from vbv_lernwelt.shop.model_utils import add_countries, remove_countries
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("shop", "0002_auto_20231114_1926"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Country",
|
||||||
|
fields=[
|
||||||
|
("country_id", models.IntegerField(primary_key=True, serialize=False)),
|
||||||
|
("name_de", models.CharField(max_length=255)),
|
||||||
|
("name_fr", models.CharField(max_length=255)),
|
||||||
|
("name_it", models.CharField(max_length=255)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Country",
|
||||||
|
"verbose_name_plural": "Countries",
|
||||||
|
"ordering": ["country_id"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="product",
|
||||||
|
name="id",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="product_sku",
|
||||||
|
field=models.CharField(max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="product",
|
||||||
|
name="sku",
|
||||||
|
field=models.CharField(max_length=255, primary_key=True, serialize=False),
|
||||||
|
),
|
||||||
|
migrations.RunPython(add_countries, remove_countries),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-11-16 12:36
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("shop", "0003_auto_20231114_2036"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
old_name="street_address",
|
||||||
|
new_name="street",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
old_name="street_number_address",
|
||||||
|
new_name="street_number",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-11-16 12:38
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("shop", "0004_auto_20231116_1336"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
old_name="company_street_address",
|
||||||
|
new_name="company_street",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
old_name="company_street_number_address",
|
||||||
|
new_name="company_street_number",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-11-16 17:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("shop", "0005_auto_20231116_1338"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="state",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("initialized", "Initialized"),
|
||||||
|
("settled", "Settled"),
|
||||||
|
("transmitted", "Transmitted"),
|
||||||
|
("canceled", "Canceled"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-11-16 23:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("shop", "0006_alter_checkoutinformation_state"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="webhook_history",
|
||||||
|
field=models.JSONField(default=list),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-11-17 08:05
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("shop", "0007_checkoutinformation_webhook_history"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="billingaddress",
|
||||||
|
name="id",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="billingaddress",
|
||||||
|
name="user",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-11-17 13:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("shop", "0008_auto_20231117_0905"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="product_price",
|
||||||
|
field=models.IntegerField(
|
||||||
|
help_text="The total price of the product in centimes -> 1000 = 10.00 CHF"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-11-26 19:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("shop", "0009_alter_checkoutinformation_product_price"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="state",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("initialized", "Initialized"),
|
||||||
|
("paid", "Paid"),
|
||||||
|
("canceled", "Canceled"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 3.2.20 on 2023-12-05 13:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("shop", "0010_alter_checkoutinformation_state"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="checkoutinformation",
|
||||||
|
name="state",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("ongoing", "Ongoing"),
|
||||||
|
("paid", "Paid"),
|
||||||
|
("canceled", "Canceled"),
|
||||||
|
("failed", "Failed"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,427 @@
|
||||||
|
countries = {
|
||||||
|
1: {"de": "Afghanistan", "fr": "Afghanistan", "it": "Afghanistan"},
|
||||||
|
2: {"de": "Albanien", "fr": "Albanie", "it": "Albania"},
|
||||||
|
3: {"de": "Algerien", "fr": "Algérie", "it": "Algeria"},
|
||||||
|
5: {"de": "Andorra", "fr": "Andorra", "it": "Andorra"},
|
||||||
|
6: {"de": "Angola", "fr": "Angola", "it": "Angola"},
|
||||||
|
9: {
|
||||||
|
"de": "Antigua und Barbuda",
|
||||||
|
"fr": "Antigua et Barbuda",
|
||||||
|
"it": "Antigua e Barbuda",
|
||||||
|
},
|
||||||
|
10: {"de": "Argentinien", "fr": "Argentine", "it": "Argentina"},
|
||||||
|
11: {"de": "Armenien", "fr": "Armenia", "it": "Armenia"},
|
||||||
|
13: {"de": "Australien", "fr": "Australie", "it": "Australia"},
|
||||||
|
14: {"de": "Österreich", "fr": "Autriche", "it": "Austria"},
|
||||||
|
15: {"de": "Aserbaidschan", "fr": "Azerbaïdjan", "it": "Azerbaijan"},
|
||||||
|
16: {"de": "Bahamas", "fr": "Bahamas", "it": "Bahamas"},
|
||||||
|
17: {"de": "Bahrain", "fr": "Bahrain", "it": "Bahrain"},
|
||||||
|
18: {"de": "Bangladesh", "fr": "Bangladesh", "it": "Bangladesh"},
|
||||||
|
19: {"de": "Barbados", "fr": "Barbados", "it": "Barbados"},
|
||||||
|
20: {"de": "Belarus", "fr": "Belarus", "it": "Belarus"},
|
||||||
|
21: {"de": "Belgien", "fr": "Belgique", "it": "Belgio"},
|
||||||
|
22: {"de": "Belize", "fr": "Belize", "it": "Belize"},
|
||||||
|
23: {"de": "Benin", "fr": "Benin", "it": "Benin"},
|
||||||
|
25: {"de": "Bhutan", "fr": "Bhutan", "it": "Bhutan"},
|
||||||
|
26: {"de": "Bolivien", "fr": "Bolivia", "it": "Bolivia"},
|
||||||
|
27: {
|
||||||
|
"de": "Bosnien und Herzegowina",
|
||||||
|
"fr": "Bosnia et Herzegowina",
|
||||||
|
"it": "Bosnia e Herzegovina",
|
||||||
|
},
|
||||||
|
28: {"de": "Botswana", "fr": "Botswana", "it": "Botswana"},
|
||||||
|
30: {"de": "Brasilien", "fr": "Brésil", "it": "Brasile"},
|
||||||
|
32: {"de": "Brunei", "fr": "Brunei", "it": "Brunei"},
|
||||||
|
33: {"de": "Bulgarien", "fr": "Bulgarie", "it": "Bulgaria"},
|
||||||
|
34: {"de": "Burkina Faso", "fr": "Burkina Faso", "it": "Burkina Faso"},
|
||||||
|
35: {"de": "Burundi", "fr": "Burundi", "it": "Burundi"},
|
||||||
|
36: {"de": "Kambodscha", "fr": "Cambodia", "it": "Cambogia"},
|
||||||
|
37: {"de": "Kamerun", "fr": "Cameroon", "it": "Camerun"},
|
||||||
|
38: {"de": "Kanada", "fr": "Canada", "it": "Canada"},
|
||||||
|
39: {"de": "Kap Verde", "fr": "Cap Vert", "it": "Capo Verde"},
|
||||||
|
41: {
|
||||||
|
"de": "Zentralafrikanische Republik",
|
||||||
|
"fr": "Centrafricaine (République)",
|
||||||
|
"it": "Repubblica Centrafricana",
|
||||||
|
},
|
||||||
|
42: {"de": "Tschad", "fr": "Tchad", "it": "Ciad"},
|
||||||
|
43: {"de": "Chile", "fr": "Chile", "it": "Cile"},
|
||||||
|
44: {"de": "Volksrepublik China", "fr": "Chine (Rép. pop.)", "it": "Cina"},
|
||||||
|
47: {"de": "Kolumbien", "fr": "Colombia", "it": "Colombia"},
|
||||||
|
48: {"de": "Komoren", "fr": "Comoros", "it": "Comoros"},
|
||||||
|
49: {
|
||||||
|
"de": "Kongo, Republik",
|
||||||
|
"fr": "Congo, Republic of the",
|
||||||
|
"it": "Congo, Repubblica del",
|
||||||
|
},
|
||||||
|
50: {
|
||||||
|
"de": "Kongo, Demokratische Republik",
|
||||||
|
"fr": "Congo, The Democratic Republic of the",
|
||||||
|
"it": "Congo, Repubblica Democratica del",
|
||||||
|
},
|
||||||
|
51: {"de": "Grenada", "fr": "Grenade", "it": "Grenada"},
|
||||||
|
52: {"de": "Costa Rica", "fr": "Costa Rica", "it": "Costa Rica"},
|
||||||
|
53: {"de": "Elfenbeinküste", "fr": "Côte d´Ivoire", "it": "Costa d´Avorio"},
|
||||||
|
54: {"de": "Kroatien", "fr": "Croatia", "it": "Croazia"},
|
||||||
|
55: {"de": "Kuba", "fr": "Cuba", "it": "Cuba"},
|
||||||
|
56: {"de": "Zypern", "fr": "Cyprus", "it": "Cipro"},
|
||||||
|
57: {
|
||||||
|
"de": "Tschechische Republik",
|
||||||
|
"fr": "Czech Rebublic",
|
||||||
|
"it": "Repubblica Ceca",
|
||||||
|
},
|
||||||
|
58: {"de": "Dänemark", "fr": "Danemark", "it": "Danimarca"},
|
||||||
|
59: {"de": "Dschibuti", "fr": "Djibouti", "it": "Gibuti"},
|
||||||
|
60: {"de": "Dominica", "fr": "Dominique", "it": "Dominica"},
|
||||||
|
61: {
|
||||||
|
"de": "Dominikanische Republik",
|
||||||
|
"fr": "République Dominicaine",
|
||||||
|
"it": "Repubblica Dominicana",
|
||||||
|
},
|
||||||
|
62: {"de": "Ost Timor", "fr": "Timor Oriental", "it": "Timor Est"},
|
||||||
|
63: {"de": "Ecuador", "fr": "Équateur", "it": "Ecuador"},
|
||||||
|
64: {"de": "Ägypten", "fr": "Égyptien", "it": "Egitto"},
|
||||||
|
65: {"de": "El Salvador", "fr": "Salvador", "it": "El Salvador"},
|
||||||
|
66: {
|
||||||
|
"de": "Äquatorialguniea",
|
||||||
|
"fr": "Guinée équatoriale",
|
||||||
|
"it": "Guinea Equatoriale",
|
||||||
|
},
|
||||||
|
67: {"de": "Eritrea", "fr": "Érythrée", "it": "Eritrea"},
|
||||||
|
68: {"de": "Estland", "fr": "Estonia", "it": "Estonia"},
|
||||||
|
69: {"de": "Äthiopien", "fr": "Éthiopie", "it": "Etiopia"},
|
||||||
|
72: {"de": "Fidschi-Inseln", "fr": "Iles Fidji", "it": "Isole Figi"},
|
||||||
|
73: {"de": "Finnland", "fr": "Finlande", "it": "Finlandia"},
|
||||||
|
74: {"de": "Frankreich", "fr": "France", "it": "Francia"},
|
||||||
|
79: {"de": "Gabun", "fr": "Gabon", "it": "Gabon"},
|
||||||
|
80: {"de": "Gambia", "fr": "Gambie", "it": "Gambia"},
|
||||||
|
81: {"de": "Georgien", "fr": "Géorgie", "it": "Georgia"},
|
||||||
|
82: {"de": "Deutschland", "fr": "Allemagne", "it": "Germania"},
|
||||||
|
83: {"de": "Ghana", "fr": "Ghana", "it": "Ghana"},
|
||||||
|
85: {"de": "Griechenland", "fr": "Grèce", "it": "Grecia"},
|
||||||
|
90: {"de": "Guatemala", "fr": "Guatemala", "it": "Guatemala"},
|
||||||
|
92: {"de": "Guinea", "fr": "Guinée", "it": "Guinea"},
|
||||||
|
93: {"de": "Guinea-Bissau", "fr": "Guinée-Bissau", "it": "Guinea-Bissau"},
|
||||||
|
94: {"de": "Guyana", "fr": "Guyana", "it": "Guyana"},
|
||||||
|
95: {"de": "Haiti", "fr": "Haïti", "it": "Haiti"},
|
||||||
|
97: {"de": "Honduras", "fr": "Honduras", "it": "Honduras"},
|
||||||
|
99: {"de": "Ungarn", "fr": "Hongrie", "it": "Ungheria"},
|
||||||
|
100: {"de": "Island", "fr": "Icelande", "it": "Islanda"},
|
||||||
|
101: {"de": "Indien", "fr": "India", "it": "India"},
|
||||||
|
102: {"de": "Indonesien", "fr": "Indonésie", "it": "Indonesia"},
|
||||||
|
103: {"de": "Iran", "fr": "Iran", "it": "Iran"},
|
||||||
|
104: {"de": "Irak", "fr": "Irak", "it": "Iraq"},
|
||||||
|
105: {"de": "Irland", "fr": "Irlande", "it": "Irlanda"},
|
||||||
|
107: {"de": "Israel", "fr": "Israël", "it": "Israele"},
|
||||||
|
108: {"de": "Italien", "fr": "Italie", "it": "Italia"},
|
||||||
|
109: {"de": "Jamaika", "fr": "Jamaïque", "it": "Giamaica"},
|
||||||
|
110: {"de": "Japan", "fr": "Japon", "it": "Giappone"},
|
||||||
|
112: {"de": "Jordanien", "fr": "Jordanie", "it": "Giordania"},
|
||||||
|
113: {"de": "Kasachstan", "fr": "Kazakstan", "it": "Kazakistan"},
|
||||||
|
114: {"de": "Kenia", "fr": "Kénia", "it": "Kenia"},
|
||||||
|
115: {"de": "Kiribati", "fr": "Kiribati", "it": "Kiribati"},
|
||||||
|
116: {
|
||||||
|
"de": "Korea, Demokratische Volksrepublik",
|
||||||
|
"fr": "Corée du Nord",
|
||||||
|
"it": "Corea, Repubblica Popolare Democratica",
|
||||||
|
},
|
||||||
|
117: {
|
||||||
|
"de": "Korea, Republik (auch: Südkorea)",
|
||||||
|
"fr": "Corée du Sud",
|
||||||
|
"it": "Corea, Repubblica (anche: Corea del Sud)",
|
||||||
|
},
|
||||||
|
118: {"de": "Kuwait", "fr": "Koweït", "it": "Kuwait"},
|
||||||
|
119: {"de": "Kirgisistan", "fr": "Kirgistan", "it": "Kirghizistan"},
|
||||||
|
120: {"de": "Laos", "fr": "Laos", "it": "Laos"},
|
||||||
|
121: {"de": "Lettland", "fr": "Lettonie", "it": "Lettonia"},
|
||||||
|
122: {"de": "Libanon", "fr": "Lebanon", "it": "Libano"},
|
||||||
|
123: {"de": "Lesotho", "fr": "Lesotho", "it": "Lesotho"},
|
||||||
|
124: {"de": "Liberia", "fr": "Liberia", "it": "Liberia"},
|
||||||
|
125: {"de": "Libyen", "fr": "Libye", "it": "Libia"},
|
||||||
|
126: {"de": "Liechtenstein", "fr": "Liechtenstein", "it": "Liechtenstein"},
|
||||||
|
127: {"de": "Litauen", "fr": "Lituanie", "it": "Lituania"},
|
||||||
|
128: {"de": "Luxembourg", "fr": "Luxembourg", "it": "Lussemburgo"},
|
||||||
|
130: {
|
||||||
|
"de": "Nordmazedonien",
|
||||||
|
"fr": "Macédoine du Nord",
|
||||||
|
"it": "Macedonia del Nord",
|
||||||
|
},
|
||||||
|
131: {"de": "Madagaskar", "fr": "Madagascar", "it": "Madagascar"},
|
||||||
|
132: {"de": "Malawi", "fr": "Malawi", "it": "Malawi"},
|
||||||
|
133: {"de": "Malaysia", "fr": "Malaisie", "it": "Malesia"},
|
||||||
|
134: {"de": "Malediven", "fr": "Maldives", "it": "Maldive"},
|
||||||
|
135: {"de": "Mali", "fr": "Mali", "it": "Mali"},
|
||||||
|
136: {"de": "Malta", "fr": "Malte", "it": "Malta"},
|
||||||
|
137: {"de": "Marshall Inseln", "fr": "Iles Marshall", "it": "Isole Marshall"},
|
||||||
|
139: {"de": "Mauretanien", "fr": "Mauritanie", "it": "Mauritania"},
|
||||||
|
140: {"de": "Mauritius", "fr": "Ile Maurice", "it": "Mauritius"},
|
||||||
|
142: {"de": "Mexico", "fr": "Mexique", "it": "Messico"},
|
||||||
|
143: {"de": "Mikronesien", "fr": "Micronésie", "it": "Micronesia"},
|
||||||
|
144: {"de": "Moldavien", "fr": "Moldavie", "it": "Moldova"},
|
||||||
|
145: {"de": "Monaco", "fr": "Monaco", "it": "Monaco"},
|
||||||
|
146: {"de": "Mongolei", "fr": "Mongolie", "it": "Mongolia"},
|
||||||
|
148: {"de": "Marokko", "fr": "Morocco", "it": "Marocco"},
|
||||||
|
149: {"de": "Mosambik", "fr": "Mozambique", "it": "Mozambico"},
|
||||||
|
150: {"de": "Myanmar", "fr": "Myanmar", "it": "Myanmar"},
|
||||||
|
151: {"de": "Namibia", "fr": "Namibie", "it": "Namibia"},
|
||||||
|
152: {"de": "Nauru", "fr": "Nauru", "it": "Nauru"},
|
||||||
|
153: {"de": "Nepal", "fr": "Népal", "it": "Nepal"},
|
||||||
|
154: {"de": "Niederlande", "fr": "Pays-Bas", "it": "Paesi Bassi"},
|
||||||
|
157: {"de": "Neuseeland", "fr": "Nouvelle-Zélande", "it": "Nuova Zelanda"},
|
||||||
|
158: {"de": "Nicaragua", "fr": "Nicaragua", "it": "Nicaragua"},
|
||||||
|
159: {"de": "Niger", "fr": "Niger", "it": "Niger"},
|
||||||
|
160: {"de": "Nigeria", "fr": "Nigeria", "it": "Nigeria"},
|
||||||
|
164: {"de": "Norwegen", "fr": "Norvège", "it": "Norvegia"},
|
||||||
|
165: {"de": "Oman", "fr": "Oman", "it": "Oman"},
|
||||||
|
166: {"de": "Pakistan", "fr": "Pakistan", "it": "Pakistan"},
|
||||||
|
167: {"de": "Palau", "fr": "Palau", "it": "Palau"},
|
||||||
|
168: {"de": "Panama", "fr": "Panama", "it": "Panama"},
|
||||||
|
170: {
|
||||||
|
"de": "Papua-Neuguinea",
|
||||||
|
"fr": "Papouasie Nouvelle-Guinée",
|
||||||
|
"it": "Papua Nuova Guinea",
|
||||||
|
},
|
||||||
|
171: {"de": "Paraguay", "fr": "Paraguay", "it": "Paraguay"},
|
||||||
|
172: {"de": "Peru", "fr": "Pérou", "it": "Perù"},
|
||||||
|
173: {"de": "Philippinen", "fr": "Philippines", "it": "Filippine"},
|
||||||
|
175: {"de": "Polen", "fr": "Pologne", "it": "Polonia"},
|
||||||
|
176: {"de": "Portugal", "fr": "Portugal", "it": "Portogallo"},
|
||||||
|
178: {"de": "Katar", "fr": "Qatar", "it": "Qatar"},
|
||||||
|
180: {"de": "Rumänien", "fr": "Roumanie", "it": "Romania"},
|
||||||
|
181: {"de": "Russische Föderation", "fr": "Russie", "it": "Russia"},
|
||||||
|
182: {"de": "Ruanda", "fr": "Ruanda", "it": "Ruanda"},
|
||||||
|
183: {
|
||||||
|
"de": "Saint Kitts und Nevis",
|
||||||
|
"fr": "Saint-Kitts-et-Nevis",
|
||||||
|
"it": "Saint Kitts e Nevis",
|
||||||
|
},
|
||||||
|
184: {"de": "St. Lucia", "fr": "Sainte-Lucie", "it": "Santa Lucia"},
|
||||||
|
185: {
|
||||||
|
"de": "St. Vincent und die Grenadinen",
|
||||||
|
"fr": "Saint-Vincent-et-Les Grenadines",
|
||||||
|
"it": "Saint Vincent e Grenadine",
|
||||||
|
},
|
||||||
|
186: {"de": "Samoa", "fr": "Samoa", "it": "Samoa"},
|
||||||
|
187: {"de": "San Marino", "fr": "San Marino", "it": "San Marino"},
|
||||||
|
188: {
|
||||||
|
"de": "Sao Tome und Principe",
|
||||||
|
"fr": "Sao Tomé-et-Principe",
|
||||||
|
"it": "São Tomé e Principe",
|
||||||
|
},
|
||||||
|
189: {"de": "Saudi-Arabien", "fr": "Arabie Saoudite", "it": "Arabia Saudita"},
|
||||||
|
190: {"de": "Senegal", "fr": "Sénégal", "it": "Senegal"},
|
||||||
|
191: {"de": "Seychellen", "fr": "Seychelles", "it": "Seychelles"},
|
||||||
|
192: {"de": "Sierra Leone", "fr": "Sierra Leone", "it": "Sierra Leone"},
|
||||||
|
193: {"de": "Singapur", "fr": "Singapour", "it": "Singapore"},
|
||||||
|
194: {"de": "Slowakei", "fr": "Slovaquie", "it": "Slovacchia"},
|
||||||
|
195: {"de": "Slowenien", "fr": "Slovénie", "it": "Slovenia"},
|
||||||
|
196: {"de": "Salomonen", "fr": "Iles Salomon", "it": "Salomone"},
|
||||||
|
197: {"de": "Somalia", "fr": "Somalie", "it": "Somalia"},
|
||||||
|
198: {"de": "Südafrika", "fr": "Afrique du Sud", "it": "Africa del Sud"},
|
||||||
|
200: {"de": "Spanien", "fr": "Espagne", "it": "Spagna"},
|
||||||
|
201: {"de": "Sri Lanka", "fr": "Sri Lanka", "it": "Sri Lanka"},
|
||||||
|
204: {"de": "Sudan", "fr": "Soudan", "it": "Sudan"},
|
||||||
|
205: {"de": "Suriname", "fr": "Suriname", "it": "Suriname"},
|
||||||
|
207: {"de": "Swasiland", "fr": "Swaziland", "it": "Swaziland"},
|
||||||
|
208: {"de": "Schweden", "fr": "Suède", "it": "Svezia"},
|
||||||
|
209: {"de": "Schweiz", "fr": "Suisse", "it": "Svizzera"},
|
||||||
|
210: {"de": "Syrien", "fr": "Syrie", "it": "Siria"},
|
||||||
|
211: {"de": "Taiwan", "fr": "Taïwan", "it": "Taiwan"},
|
||||||
|
212: {"de": "Tadschikistan", "fr": "Tadjikistan", "it": "Tagikistan"},
|
||||||
|
213: {"de": "Tansania", "fr": "Tanzanie", "it": "Tanzania"},
|
||||||
|
214: {"de": "Thailand", "fr": "Thaïlande", "it": "Tailandia"},
|
||||||
|
215: {"de": "Togo", "fr": "Togo", "it": "Togo"},
|
||||||
|
217: {"de": "Tonga", "fr": "Tonga", "it": "Tonga"},
|
||||||
|
218: {
|
||||||
|
"de": "Trinidad und Tobago",
|
||||||
|
"fr": "Trinité-et-Tobago",
|
||||||
|
"it": "Trinidad e Tobago",
|
||||||
|
},
|
||||||
|
219: {"de": "Tunesien", "fr": "Tunisie", "it": "Tunisia"},
|
||||||
|
220: {"de": "Türkei", "fr": "Turchia", "it": "Turchia"},
|
||||||
|
221: {"de": "Turkmenistan", "fr": "Turkménistan", "it": "Turkmenistan"},
|
||||||
|
223: {"de": "Tuvalu", "fr": "Tuvalu", "it": "Tuvalu"},
|
||||||
|
224: {"de": "Uganda", "fr": "Ouganda", "it": "Uganda"},
|
||||||
|
225: {"de": "Ukraine", "fr": "Ukraine", "it": "Ucraina"},
|
||||||
|
226: {
|
||||||
|
"de": "Vereinigte Arabische Emirate",
|
||||||
|
"fr": "Émirats Arabes Unis",
|
||||||
|
"it": "Emirati Arabi Uniti",
|
||||||
|
},
|
||||||
|
227: {"de": "Großbritannien", "fr": "Royaume-Uni", "it": "Regno Unito"},
|
||||||
|
228: {"de": "USA", "fr": "États-Unis", "it": "Stati Uniti d´ America"},
|
||||||
|
230: {"de": "Uruguay", "fr": "Uruguay", "it": "Uruguay"},
|
||||||
|
231: {"de": "Usbekistan", "fr": "Ouzbékistan", "it": "Uzbekistan"},
|
||||||
|
232: {"de": "Vanuatu", "fr": "Vanuatu", "it": "Vanuatu"},
|
||||||
|
233: {"de": "Vatikanstadt", "fr": "Vatican", "it": "Città del Vaticano"},
|
||||||
|
234: {"de": "Venezuela", "fr": "Venezuela", "it": "Venezuela"},
|
||||||
|
235: {"de": "Vietnam", "fr": "Viêtnam", "it": "Vietnam"},
|
||||||
|
239: {"de": "Sahara", "fr": "Sahara", "it": "Sahara"},
|
||||||
|
240: {"de": "Jemen", "fr": "Yémen", "it": "Yemen"},
|
||||||
|
241: {"de": "Serbien", "fr": "Serbie", "it": "Serbia"},
|
||||||
|
242: {"de": "Montenegro", "fr": "Monténégro", "it": "Montenegro"},
|
||||||
|
243: {"de": "Sambia", "fr": "Zambie", "it": "Zambia"},
|
||||||
|
244: {"de": "Simbabwe", "fr": "Zimbabwe", "it": "Zimbabwe"},
|
||||||
|
245: {"de": "Hong Kong", "fr": "Hong Kong", "it": "Hong Kong"},
|
||||||
|
246: {"de": "Falkland Inseln", "fr": "Îles Malouines", "it": "Isole Falkland"},
|
||||||
|
247: {"de": "Aruba", "fr": "Aruba", "it": "Aruba"},
|
||||||
|
248: {"de": "Bermuda", "fr": "Bermudes", "it": "Bermuda"},
|
||||||
|
249: {
|
||||||
|
"de": "Britische Jungferninseln",
|
||||||
|
"fr": "Îles Vierges britanniques",
|
||||||
|
"it": "Isole Vergini britanniche",
|
||||||
|
},
|
||||||
|
250: {"de": "Curaçao", "fr": "Curaçao", "it": "Curaçao"},
|
||||||
|
251: {"de": "Anguilla", "fr": "Anguilla", "it": "Anguilla"},
|
||||||
|
252: {"de": "Montserrat", "fr": "Montserrat", "it": "Montserrat"},
|
||||||
|
253: {
|
||||||
|
"de": "Bonaire, Sint Eustatius und Saba",
|
||||||
|
"fr": "Bonaire, Saint-Eustache et Saba",
|
||||||
|
"it": "Bonaire, Sint Eustatius e Saba",
|
||||||
|
},
|
||||||
|
254: {"de": "Cayman Inseln", "fr": "Îles Caïmans", "it": "Isole Cayman"},
|
||||||
|
255: {"de": "Sint Maarten", "fr": "Saint-Martin", "it": "Sint Maarten"},
|
||||||
|
256: {
|
||||||
|
"de": "Turks- und Caicos-Inseln",
|
||||||
|
"fr": "Îles Turks et Caïques",
|
||||||
|
"it": "Turks e Caicos",
|
||||||
|
},
|
||||||
|
257: {"de": "Saint-Barth", "fr": "Saint-Barthélemy", "it": "Saint-Barth"},
|
||||||
|
258: {
|
||||||
|
"de": "Palästinensisches Gebiet",
|
||||||
|
"fr": "Territoires palestiniens occupés",
|
||||||
|
"it": "Territori palestinesi",
|
||||||
|
},
|
||||||
|
259: {"de": "Kosovo", "fr": "Kosovo", "it": "Kosovo"},
|
||||||
|
260: {"de": "Gibraltar", "fr": "Gibraltar", "it": "Gibilterra"},
|
||||||
|
261: {"de": "Neukaledonien", "fr": "Nouvelle-Calédonie", "it": "Nuova Caledonia"},
|
||||||
|
262: {
|
||||||
|
"de": "Französisch-Polynesien",
|
||||||
|
"fr": "Polynésie française",
|
||||||
|
"it": "Polinesia francese",
|
||||||
|
},
|
||||||
|
310: {
|
||||||
|
"de": "Niederländische Antillen",
|
||||||
|
"fr": "Antilles néerlandaises",
|
||||||
|
"it": "Antille olandesi",
|
||||||
|
},
|
||||||
|
311: {"de": "Antarktika", "fr": "Antarctique", "it": "Antartide"},
|
||||||
|
312: {
|
||||||
|
"de": "Amerikanisch-Samoa",
|
||||||
|
"fr": "Samoa américaines",
|
||||||
|
"it": "Samoa americane",
|
||||||
|
},
|
||||||
|
313: {"de": "Åland", "fr": "Åland", "it": "Åland"},
|
||||||
|
314: {"de": "Bouvetinsel", "fr": "Île Bouvet", "it": "Isola Bouvet"},
|
||||||
|
315: {"de": "Kokosinseln", "fr": "Îles Cocos", "it": "Isole Cocos (Keeling)"},
|
||||||
|
316: {"de": "Cookinseln", "fr": "Îles Cook", "it": "Isole Cook"},
|
||||||
|
317: {
|
||||||
|
"de": "Clipperton-Insel",
|
||||||
|
"fr": "Île de Clipperton",
|
||||||
|
"it": "Isola di Clipperton",
|
||||||
|
},
|
||||||
|
318: {"de": "Weihnachtsinsel", "fr": "Île Christmas", "it": "Isola di Natale"},
|
||||||
|
319: {"de": "Färöer-Inseln", "fr": "Îles Féroé", "it": "Isole Färöer"},
|
||||||
|
320: {
|
||||||
|
"de": "Französisch-Guayana",
|
||||||
|
"fr": "Guyane française",
|
||||||
|
"it": "Guyana francese",
|
||||||
|
},
|
||||||
|
321: {"de": "Guernsey", "fr": "Guernsey", "it": "Guernsey"},
|
||||||
|
322: {"de": "Grönland", "fr": "Groenland", "it": "Groenlandia"},
|
||||||
|
323: {"de": "Guadeloupe", "fr": "Guadeloupe", "it": "Guadalupa"},
|
||||||
|
324: {
|
||||||
|
"de": "Südgeorgien und die Südlichen Sandwichinseln",
|
||||||
|
"fr": "Géorgie du Sud et Îles Sandwich du Sud",
|
||||||
|
"it": "Georgia del Sud e Sandwich Australi",
|
||||||
|
},
|
||||||
|
325: {"de": "Guam", "fr": "Guam", "it": "Guam"},
|
||||||
|
326: {
|
||||||
|
"de": "Heard und McDonaldinseln",
|
||||||
|
"fr": "Îles Heard et McDonald",
|
||||||
|
"it": "Isola Heard e Isole McDonald",
|
||||||
|
},
|
||||||
|
327: {"de": "Insel Man", "fr": "Île de Man", "it": "Isola di Man"},
|
||||||
|
328: {
|
||||||
|
"de": "Britisches Territorium im Indischen Ozean",
|
||||||
|
"fr": "Territoire britannique de l´océan Indien",
|
||||||
|
"it": "Territori Britannici dell´Oceano Indiano",
|
||||||
|
},
|
||||||
|
329: {"de": "Jersey", "fr": "Jersey", "it": "Jersey"},
|
||||||
|
330: {"de": "Saint-Martin", "fr": "Saint-Martin", "it": "Saint Martin"},
|
||||||
|
331: {"de": "Macau", "fr": "Macao", "it": "Macao"},
|
||||||
|
332: {
|
||||||
|
"de": "Nördliche Marianen",
|
||||||
|
"fr": "Îles Mariannes du Nord",
|
||||||
|
"it": "Isole Marianne Settentrionali",
|
||||||
|
},
|
||||||
|
333: {"de": "Martinique", "fr": "Martinique", "it": "Martinica"},
|
||||||
|
334: {"de": "Norfolkinsel", "fr": "Île Norfolk", "it": "Isola Norfolk"},
|
||||||
|
335: {"de": "Niue", "fr": "Niue", "it": "Niue"},
|
||||||
|
336: {
|
||||||
|
"de": "Saint-Pierre und Miquelon",
|
||||||
|
"fr": "Saint-Pierre-et-Miquelon",
|
||||||
|
"it": "Saint-Pierre e Miquelon",
|
||||||
|
},
|
||||||
|
337: {"de": "Pitcairninseln", "fr": "Îles Pitcairn", "it": "Isole Pitcairn"},
|
||||||
|
338: {"de": "Puerto Rico", "fr": "Porto Rico", "it": "Porto Rico"},
|
||||||
|
339: {"de": "La Réunion", "fr": "La Réunion", "it": "Isola della Riunione"},
|
||||||
|
340: {
|
||||||
|
"de": "St. Helena, Ascension und Tristan da Cunha",
|
||||||
|
"fr": "Sainte-Hélène, Ascension et Tristan da Cunha",
|
||||||
|
"it": "Sant´Elena, Ascensione e Tristan da Cunha",
|
||||||
|
},
|
||||||
|
341: {
|
||||||
|
"de": "Spitzbergen, Jan Mayen",
|
||||||
|
"fr": "Spitzberg, Jan Mayen",
|
||||||
|
"it": "Svalbard e Jan Mayen",
|
||||||
|
},
|
||||||
|
342: {"de": "Südsudan", "fr": "Sud-Soudan", "it": "Sudan del Sud"},
|
||||||
|
343: {
|
||||||
|
"de": "Französische Süd- und Antarktisgebiete",
|
||||||
|
"fr": "Terres australes et antarctiques françaises",
|
||||||
|
"it": "Territori australi e antartico francese",
|
||||||
|
},
|
||||||
|
344: {"de": "Tokelau", "fr": "Tokelau", "it": "Tokelau"},
|
||||||
|
345: {
|
||||||
|
"de": "United States Minor Outlying Islands",
|
||||||
|
"fr": "Îles mineures éloignées des États-Unis",
|
||||||
|
"it": "Isole Minori Esterne degli Stati Uniti",
|
||||||
|
},
|
||||||
|
346: {
|
||||||
|
"de": "Amerikanische Jungferninseln",
|
||||||
|
"fr": "Îles Vierges américaines",
|
||||||
|
"it": "Isole Vergini Americane",
|
||||||
|
},
|
||||||
|
347: {"de": "Wallis und Futuna", "fr": "Wallis et Futuna", "it": "Wallis e Futuna"},
|
||||||
|
348: {"de": "Mayotte", "fr": "Mayotte", "it": "Mayotte"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def add_countries(apps=None, schema_editor=None):
|
||||||
|
if apps is None:
|
||||||
|
# pylint: disable=import-outside-toplevel
|
||||||
|
from vbv_lernwelt.shop.models import Country
|
||||||
|
else:
|
||||||
|
Country = apps.get_model("shop", "Country")
|
||||||
|
|
||||||
|
for country_id, country_name in countries.items():
|
||||||
|
Country.objects.get_or_create(
|
||||||
|
country_id=country_id,
|
||||||
|
name_de=country_name["de"],
|
||||||
|
name_fr=country_name["fr"],
|
||||||
|
name_it=country_name["it"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_countries(apps=None, schema_editor=None):
|
||||||
|
if apps is None:
|
||||||
|
# pylint: disable=import-outside-toplevel
|
||||||
|
from vbv_lernwelt.shop.models import Country
|
||||||
|
else:
|
||||||
|
Country = apps.get_model("shop", "Country")
|
||||||
|
|
||||||
|
for country_id in countries.keys():
|
||||||
|
Country.objects.filter(
|
||||||
|
country_id=country_id,
|
||||||
|
).delete()
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Country(models.Model):
|
||||||
|
country_id = models.IntegerField(primary_key=True)
|
||||||
|
name_de = models.CharField(max_length=255)
|
||||||
|
name_fr = models.CharField(max_length=255)
|
||||||
|
name_it = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name_de} ({self.country_id})"
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Country"
|
||||||
|
verbose_name_plural = "Countries"
|
||||||
|
ordering = ["country_id"]
|
||||||
|
|
||||||
|
|
||||||
|
class BillingAddress(models.Model):
|
||||||
|
"""
|
||||||
|
Draft of a billing address for a purchase from the shop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = models.OneToOneField(
|
||||||
|
"core.User",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
primary_key=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# user
|
||||||
|
first_name = models.CharField(max_length=255, blank=True)
|
||||||
|
last_name = models.CharField(max_length=255, blank=True)
|
||||||
|
street = models.CharField(max_length=255, blank=True)
|
||||||
|
street_number = models.CharField(max_length=255, blank=True)
|
||||||
|
postal_code = models.CharField(max_length=255, blank=True)
|
||||||
|
city = models.CharField(max_length=255, blank=True)
|
||||||
|
country = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
|
# company (optional)
|
||||||
|
company_name = models.CharField(max_length=255, blank=True)
|
||||||
|
company_street = models.CharField(max_length=255, blank=True)
|
||||||
|
company_street_number = models.CharField(max_length=255, blank=True)
|
||||||
|
company_postal_code = models.CharField(max_length=255, blank=True)
|
||||||
|
company_city = models.CharField(max_length=255, blank=True)
|
||||||
|
company_country = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Product(models.Model):
|
||||||
|
sku = models.CharField(max_length=255, primary_key=True)
|
||||||
|
price = models.IntegerField() # 10_00 = 10.00 CHF
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutState(models.TextChoices):
|
||||||
|
"""
|
||||||
|
The state of a checkout process transaction.
|
||||||
|
|
||||||
|
PAID: Datatrans transaction settled/transmitted.
|
||||||
|
ONGOING: Any state that is not final (e.g. initialized, challenge_ongoing, etc.)
|
||||||
|
|
||||||
|
1) We use the `autoSettle` feature of DataTrans!
|
||||||
|
-> https://docs.datatrans.ch/docs/after-the-payment
|
||||||
|
-> https://api-reference.datatrans.ch/#tag/v1transactions/operation/status
|
||||||
|
|
||||||
|
2) Difference between `settled` and `transmitted`:
|
||||||
|
- https://www.datatrans.ch/en/know-how/faq/#what-does-the-status-transaction-settled-or-settledtransmitted-mean
|
||||||
|
|
||||||
|
3) Related code: init_transaction and get_transaction_state in shop/services.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
ONGOING = "ongoing"
|
||||||
|
PAID = "paid"
|
||||||
|
CANCELED = "canceled"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutInformation(models.Model):
|
||||||
|
user = models.ForeignKey("core.User", on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
product_sku = models.CharField(max_length=255)
|
||||||
|
product_name = models.CharField(max_length=255)
|
||||||
|
product_description = models.CharField(max_length=255)
|
||||||
|
product_price = models.IntegerField(
|
||||||
|
help_text="The total price of the product in centimes -> 1000 = 10.00 CHF"
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
state = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=CheckoutState.choices,
|
||||||
|
)
|
||||||
|
|
||||||
|
invoice_transmitted_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
transaction_id = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
# end user (required)
|
||||||
|
first_name = models.CharField(max_length=255)
|
||||||
|
last_name = models.CharField(max_length=255)
|
||||||
|
street = models.CharField(max_length=255)
|
||||||
|
street_number = models.CharField(max_length=255)
|
||||||
|
postal_code = models.CharField(max_length=255)
|
||||||
|
city = models.CharField(max_length=255)
|
||||||
|
country = models.CharField(max_length=255)
|
||||||
|
|
||||||
|
# company (optional)
|
||||||
|
company_name = models.CharField(max_length=255, blank=True)
|
||||||
|
company_street = models.CharField(max_length=255, blank=True)
|
||||||
|
company_street_number = models.CharField(max_length=255, blank=True)
|
||||||
|
company_postal_code = models.CharField(max_length=255, blank=True)
|
||||||
|
company_city = models.CharField(max_length=255, blank=True)
|
||||||
|
company_country = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
|
# webhook metadata
|
||||||
|
webhook_history = models.JSONField(default=list)
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .models import BillingAddress, Country
|
||||||
|
|
||||||
|
|
||||||
|
class BillingAddressSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = BillingAddress
|
||||||
|
fields = [
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"street",
|
||||||
|
"street_number",
|
||||||
|
"postal_code",
|
||||||
|
"city",
|
||||||
|
"country",
|
||||||
|
"company_name",
|
||||||
|
"company_street",
|
||||||
|
"company_street_number",
|
||||||
|
"company_postal_code",
|
||||||
|
"company_city",
|
||||||
|
"company_country",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CountrySerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Country
|
||||||
|
fields = "__all__"
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import structlog
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.admin import User
|
||||||
|
from vbv_lernwelt.shop.models import CheckoutState
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class InitTransactionException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def is_signature_valid(
|
||||||
|
signature: str,
|
||||||
|
payload: bytes,
|
||||||
|
hmac_key: str = settings.DATATRANS_HMAC_KEY,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
See the docs: https://docs.datatrans.ch/docs/additional-security
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp = signature.split(",")[0].split("=")[1]
|
||||||
|
s0_expected = signature.split(",")[1].split("=")[1]
|
||||||
|
|
||||||
|
key_hex_bytes = bytes.fromhex(hmac_key)
|
||||||
|
timestamp_bytes = bytes(timestamp, "utf-8")
|
||||||
|
|
||||||
|
s0_actual = hmac.new(
|
||||||
|
key_hex_bytes, timestamp_bytes + payload, hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
logger.warning(
|
||||||
|
"Invalid signature format Expected format: t=TIMESTAMP,s0=XXXX",
|
||||||
|
signature=signature,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return s0_actual == s0_expected
|
||||||
|
|
||||||
|
|
||||||
|
def init_transaction(
|
||||||
|
user: User,
|
||||||
|
amount_chf_centimes: int,
|
||||||
|
redirect_url_success: str,
|
||||||
|
redirect_url_error: str,
|
||||||
|
redirect_url_cancel: str,
|
||||||
|
webhook_url: str,
|
||||||
|
):
|
||||||
|
if overwrite := settings.DATATRANS_DEBUG_WEBHOOK_OVERWRITE:
|
||||||
|
logger.warning(
|
||||||
|
"APPLYING DEBUG DATATRANS WEBHOOK OVERWRITE!",
|
||||||
|
webhook_url=overwrite,
|
||||||
|
)
|
||||||
|
webhook_url = overwrite
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
# We use autoSettle=True, so that we don't have to settle the transaction:
|
||||||
|
# -> Be aware that autoSettle has implications of the possible transaction states
|
||||||
|
"autoSettle": True,
|
||||||
|
"amount": amount_chf_centimes,
|
||||||
|
"currency": "CHF",
|
||||||
|
"language": user.language,
|
||||||
|
"refno": str(uuid.uuid4()),
|
||||||
|
"webhook": {"url": webhook_url},
|
||||||
|
"redirect": {
|
||||||
|
"successUrl": redirect_url_success,
|
||||||
|
"errorUrl": redirect_url_error,
|
||||||
|
"cancelUrl": redirect_url_cancel,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Initiating transaction", payload=payload)
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
url=f"{settings.DATATRANS_API_ENDPOINT}/v1/transactions",
|
||||||
|
json=payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {settings.DATATRANS_BASIC_AUTH_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 201:
|
||||||
|
transaction_id = response.json()["transactionId"]
|
||||||
|
logger.info("Transaction initiated", transaction_id=transaction_id)
|
||||||
|
return transaction_id
|
||||||
|
else:
|
||||||
|
raise InitTransactionException(
|
||||||
|
"Transaction initiation failed:",
|
||||||
|
response.json().get("error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_transaction_state(
|
||||||
|
transaction_id: str,
|
||||||
|
) -> CheckoutState:
|
||||||
|
response = requests.get(
|
||||||
|
f"{settings.DATATRANS_API_ENDPOINT}/v1/transactions/{transaction_id}",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {settings.DATATRANS_BASIC_AUTH_KEY}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
transaction_state = response.json()["status"]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Transaction status retrieved",
|
||||||
|
status_code=response.status_code,
|
||||||
|
response=transaction_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
return datatrans_state_to_checkout_state(transaction_state)
|
||||||
|
|
||||||
|
|
||||||
|
def get_payment_url(transaction_id: str):
|
||||||
|
return f"{settings.DATATRANS_PAY_URL}/v1/start/{transaction_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def datatrans_state_to_checkout_state(datatrans_transaction_state) -> CheckoutState:
|
||||||
|
"""
|
||||||
|
https://api-reference.datatrans.ch/#tag/v1transactions/operation/status
|
||||||
|
"""
|
||||||
|
if datatrans_transaction_state in ["settled", "transmitted"]:
|
||||||
|
return CheckoutState.PAID
|
||||||
|
elif datatrans_transaction_state == "failed":
|
||||||
|
return CheckoutState.FAILED
|
||||||
|
elif datatrans_transaction_state == "canceled":
|
||||||
|
return CheckoutState.CANCELED
|
||||||
|
else:
|
||||||
|
# An intermediate state such as "initialized", "challenge_ongoing", etc.
|
||||||
|
# -> we don't care about those states, we only care about final states here.
|
||||||
|
return CheckoutState.ONGOING
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.admin import User
|
||||||
|
from vbv_lernwelt.shop.models import BillingAddress
|
||||||
|
|
||||||
|
|
||||||
|
class BillingAddressViewTest(APITestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
"testuser", "test@example.com", "testpassword"
|
||||||
|
)
|
||||||
|
self.client.login(username="testuser", password="testpassword")
|
||||||
|
|
||||||
|
self.billing_address = BillingAddress.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
first_name="John",
|
||||||
|
last_name="Doe",
|
||||||
|
street="123 Main St",
|
||||||
|
street_number="45A",
|
||||||
|
postal_code="12345",
|
||||||
|
city="Test City",
|
||||||
|
country="Test Country",
|
||||||
|
company_name="Test Company",
|
||||||
|
company_street="456 Company St",
|
||||||
|
company_street_number="67B",
|
||||||
|
company_postal_code="67890",
|
||||||
|
company_city="Company City",
|
||||||
|
company_country="Company Country",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_billing_address(self) -> None:
|
||||||
|
# GIVEN
|
||||||
|
# user is logged in and has a billing address
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
url = reverse("get-billing-address")
|
||||||
|
response = self.client.get(url)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data["first_name"], "John")
|
||||||
|
self.assertEqual(response.data["last_name"], "Doe")
|
||||||
|
self.assertEqual(response.data["street"], "123 Main St")
|
||||||
|
self.assertEqual(response.data["street_number"], "45A")
|
||||||
|
self.assertEqual(response.data["postal_code"], "12345")
|
||||||
|
self.assertEqual(response.data["city"], "Test City")
|
||||||
|
self.assertEqual(response.data["country"], "Test Country")
|
||||||
|
self.assertEqual(response.data["company_name"], "Test Company")
|
||||||
|
self.assertEqual(response.data["company_street"], "456 Company St")
|
||||||
|
self.assertEqual(response.data["company_street_number"], "67B")
|
||||||
|
self.assertEqual(response.data["company_postal_code"], "67890")
|
||||||
|
self.assertEqual(response.data["company_city"], "Company City")
|
||||||
|
self.assertEqual(response.data["company_country"], "Company Country")
|
||||||
|
|
||||||
|
def test_update_billing_address(self) -> None:
|
||||||
|
# GIVEN
|
||||||
|
new_data = {
|
||||||
|
"first_name": "Jane",
|
||||||
|
"last_name": "Smith",
|
||||||
|
"street": "789 New St",
|
||||||
|
"street_number": "101C",
|
||||||
|
"postal_code": "54321",
|
||||||
|
"city": "New City",
|
||||||
|
"country": "New Country",
|
||||||
|
"company_name": "New Company",
|
||||||
|
"company_street": "789 Company St",
|
||||||
|
"company_street_number": "102D",
|
||||||
|
"company_postal_code": "98765",
|
||||||
|
"company_city": "New Company City",
|
||||||
|
"company_country": "New Company Country",
|
||||||
|
}
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
url = reverse("update-billing-address")
|
||||||
|
response = self.client.put(url, new_data)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
updated_address = BillingAddress.objects.get(user=self.user)
|
||||||
|
self.assertEqual(updated_address.first_name, "Jane")
|
||||||
|
self.assertEqual(updated_address.last_name, "Smith")
|
||||||
|
self.assertEqual(updated_address.street, "789 New St")
|
||||||
|
self.assertEqual(updated_address.street_number, "101C")
|
||||||
|
self.assertEqual(updated_address.postal_code, "54321")
|
||||||
|
self.assertEqual(updated_address.city, "New City")
|
||||||
|
self.assertEqual(updated_address.country, "New Country")
|
||||||
|
self.assertEqual(updated_address.company_name, "New Company")
|
||||||
|
self.assertEqual(updated_address.company_street, "789 Company St")
|
||||||
|
self.assertEqual(updated_address.company_street_number, "102D")
|
||||||
|
self.assertEqual(updated_address.company_postal_code, "98765")
|
||||||
|
self.assertEqual(updated_address.company_city, "New Company City")
|
||||||
|
self.assertEqual(updated_address.company_country, "New Company Country")
|
||||||
|
|
||||||
|
def test_unauthenticated_access(self) -> None:
|
||||||
|
# GIVEN
|
||||||
|
self.client.logout()
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
get_response = self.client.get(reverse("get-billing-address"))
|
||||||
|
put_response = self.client.put(reverse("update-billing-address"), {})
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertTrue(get_response["Location"], "/login/")
|
||||||
|
self.assertTrue(put_response["Location"], "/login/")
|
||||||
|
|
@ -0,0 +1,305 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.admin import User
|
||||||
|
from vbv_lernwelt.shop.const import VV_DE_PRODUCT_SKU
|
||||||
|
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState, Product
|
||||||
|
from vbv_lernwelt.shop.services import InitTransactionException
|
||||||
|
|
||||||
|
USER_USERNAME = "testuser"
|
||||||
|
USER_EMAIL = "test@example.com"
|
||||||
|
USER_PASSWORD = "testpassword"
|
||||||
|
|
||||||
|
TEST_ADDRESS_DATA = {
|
||||||
|
"first_name": "Test",
|
||||||
|
"last_name": "User",
|
||||||
|
"street": "Test Street",
|
||||||
|
"street_number": "1",
|
||||||
|
"postal_code": "1234",
|
||||||
|
"city": "Test City",
|
||||||
|
"country": "Test Country",
|
||||||
|
"company_name": "Test Company",
|
||||||
|
"company_street": "Test Company Street",
|
||||||
|
"company_street_number": "1",
|
||||||
|
"company_postal_code": "1234",
|
||||||
|
"company_city": "Test Company City",
|
||||||
|
"company_country": "Test Company Country",
|
||||||
|
}
|
||||||
|
|
||||||
|
REDIRECT_URL = "http://testserver/redirect-url"
|
||||||
|
|
||||||
|
|
||||||
|
class CheckoutAPITestCase(APITestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
Product.objects.create(
|
||||||
|
sku=VV_DE_PRODUCT_SKU,
|
||||||
|
price=324_30,
|
||||||
|
description="VV",
|
||||||
|
name="VV",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username=USER_USERNAME,
|
||||||
|
email=USER_EMAIL,
|
||||||
|
password=USER_PASSWORD,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.login(username=USER_USERNAME, password=USER_PASSWORD)
|
||||||
|
|
||||||
|
@patch("vbv_lernwelt.shop.views.init_transaction")
|
||||||
|
def test_checkout_happy_case(self, mock_init_transaction):
|
||||||
|
# GIVEN
|
||||||
|
mock_init_transaction.return_value = "1234567890"
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.post(
|
||||||
|
path=reverse("checkout-vv"),
|
||||||
|
format="json",
|
||||||
|
data={
|
||||||
|
"redirect_url": REDIRECT_URL,
|
||||||
|
"product": VV_DE_PRODUCT_SKU,
|
||||||
|
"address": TEST_ADDRESS_DATA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
f"https://pay.sandbox.datatrans.com/v1/start/1234567890",
|
||||||
|
response.json()["next_step_url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
CheckoutInformation.objects.filter(
|
||||||
|
user=self.user,
|
||||||
|
product_sku=VV_DE_PRODUCT_SKU,
|
||||||
|
state=CheckoutState.ONGOING,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_init_transaction.assert_called_once_with(
|
||||||
|
user=self.user,
|
||||||
|
amount_chf_centimes=324_30,
|
||||||
|
redirect_url_success=f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/complete",
|
||||||
|
redirect_url_error=f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/address?error",
|
||||||
|
redirect_url_cancel=f"{REDIRECT_URL}/",
|
||||||
|
webhook_url=f"{REDIRECT_URL}/api/shop/transaction/webhook/",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("vbv_lernwelt.shop.views.init_transaction")
|
||||||
|
def test_incomplete_setup(self, mock_init_transaction):
|
||||||
|
# GIVEN
|
||||||
|
Product.objects.all().delete()
|
||||||
|
mock_init_transaction.return_value = "1234567890"
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.post(
|
||||||
|
path=reverse("checkout-vv"),
|
||||||
|
format="json",
|
||||||
|
data={
|
||||||
|
"redirect_url": REDIRECT_URL,
|
||||||
|
"product": VV_DE_PRODUCT_SKU,
|
||||||
|
"address": TEST_ADDRESS_DATA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
expected = (
|
||||||
|
f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/address?error&"
|
||||||
|
f"message=vv-de_product_sku_does_not_exist_needs_to_be_created_first"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(expected, response.json()["next_step_url"])
|
||||||
|
|
||||||
|
@patch("vbv_lernwelt.shop.views.init_transaction")
|
||||||
|
def test_checkout_init_transaction_exception(self, mock_init_transaction):
|
||||||
|
# GIVEN
|
||||||
|
mock_init_transaction.side_effect = InitTransactionException(
|
||||||
|
"Something went wrong"
|
||||||
|
)
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.post(
|
||||||
|
path=reverse("checkout-vv"),
|
||||||
|
format="json",
|
||||||
|
data={
|
||||||
|
"redirect_url": REDIRECT_URL,
|
||||||
|
"product": VV_DE_PRODUCT_SKU,
|
||||||
|
"address": TEST_ADDRESS_DATA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/address?error",
|
||||||
|
response.json()["next_step_url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
0,
|
||||||
|
CheckoutInformation.objects.count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_checkout_already_paid(self):
|
||||||
|
# GIVEN
|
||||||
|
CheckoutInformation.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
product_sku=VV_DE_PRODUCT_SKU,
|
||||||
|
product_price=0,
|
||||||
|
state=CheckoutState.PAID,
|
||||||
|
)
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.post(
|
||||||
|
path=reverse("checkout-vv"),
|
||||||
|
format="json",
|
||||||
|
data={
|
||||||
|
"redirect_url": REDIRECT_URL,
|
||||||
|
"product": VV_DE_PRODUCT_SKU,
|
||||||
|
"address": TEST_ADDRESS_DATA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
"/",
|
||||||
|
response.json()["next_step_url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("vbv_lernwelt.shop.views.init_transaction")
|
||||||
|
def test_checkout_double_checkout(self, mock_init_transaction):
|
||||||
|
"""Advise by Datatrans: Just create a new transaction."""
|
||||||
|
# GIVEN
|
||||||
|
|
||||||
|
# existing checkout
|
||||||
|
transaction_id_previous = "1234567890"
|
||||||
|
CheckoutInformation.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
product_sku=VV_DE_PRODUCT_SKU,
|
||||||
|
product_price=0,
|
||||||
|
state=CheckoutState.ONGOING,
|
||||||
|
transaction_id=transaction_id_previous,
|
||||||
|
)
|
||||||
|
|
||||||
|
# new checkout / transaction
|
||||||
|
transaction_id_next = "9999999999"
|
||||||
|
mock_init_transaction.return_value = transaction_id_next
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.post(
|
||||||
|
path=reverse("checkout-vv"),
|
||||||
|
format="json",
|
||||||
|
data={
|
||||||
|
"redirect_url": REDIRECT_URL,
|
||||||
|
"product": VV_DE_PRODUCT_SKU,
|
||||||
|
"address": TEST_ADDRESS_DATA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id_next}",
|
||||||
|
response.json()["next_step_url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# check that we have two checkouts
|
||||||
|
# (one previous and one new)
|
||||||
|
self.assertEqual(
|
||||||
|
2,
|
||||||
|
CheckoutInformation.objects.count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# previous checkout
|
||||||
|
self.assertTrue(
|
||||||
|
CheckoutInformation.objects.filter(
|
||||||
|
user=self.user,
|
||||||
|
product_sku=VV_DE_PRODUCT_SKU,
|
||||||
|
state=CheckoutState.ONGOING,
|
||||||
|
transaction_id=transaction_id_previous,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
# new checkout
|
||||||
|
self.assertTrue(
|
||||||
|
CheckoutInformation.objects.filter(
|
||||||
|
user=self.user,
|
||||||
|
product_sku=VV_DE_PRODUCT_SKU,
|
||||||
|
state=CheckoutState.ONGOING,
|
||||||
|
transaction_id=transaction_id_next,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("vbv_lernwelt.shop.views.init_transaction")
|
||||||
|
def test_checkout_failed_creates_new(self, mock_init_transaction):
|
||||||
|
# GIVEN
|
||||||
|
state = CheckoutState.FAILED
|
||||||
|
transaction_id = "1234567890"
|
||||||
|
mock_init_transaction.return_value = transaction_id
|
||||||
|
|
||||||
|
CheckoutInformation.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
product_sku=VV_DE_PRODUCT_SKU,
|
||||||
|
product_price=0,
|
||||||
|
state=state,
|
||||||
|
transaction_id="0000000000",
|
||||||
|
)
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.post(
|
||||||
|
path=reverse("checkout-vv"),
|
||||||
|
format="json",
|
||||||
|
data={
|
||||||
|
"redirect_url": REDIRECT_URL,
|
||||||
|
"product": VV_DE_PRODUCT_SKU,
|
||||||
|
"address": TEST_ADDRESS_DATA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id}",
|
||||||
|
response.json()["next_step_url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("vbv_lernwelt.shop.views.init_transaction")
|
||||||
|
def test_checkout_cancelled_creates_new(self, mock_init_transaction):
|
||||||
|
# GIVEN
|
||||||
|
state = CheckoutState.CANCELED
|
||||||
|
transaction_id = "1234567899"
|
||||||
|
mock_init_transaction.return_value = transaction_id
|
||||||
|
|
||||||
|
CheckoutInformation.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
product_sku=VV_DE_PRODUCT_SKU,
|
||||||
|
product_price=0,
|
||||||
|
state=state,
|
||||||
|
transaction_id="1111111111",
|
||||||
|
)
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.post(
|
||||||
|
path=reverse("checkout-vv"),
|
||||||
|
format="json",
|
||||||
|
data={
|
||||||
|
"redirect_url": REDIRECT_URL,
|
||||||
|
"product": VV_DE_PRODUCT_SKU,
|
||||||
|
"address": TEST_ADDRESS_DATA,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id}",
|
||||||
|
response.json()["next_step_url"],
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import override_settings, TestCase
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.models import User
|
||||||
|
from vbv_lernwelt.shop.services import (
|
||||||
|
get_payment_url,
|
||||||
|
init_transaction,
|
||||||
|
InitTransactionException,
|
||||||
|
)
|
||||||
|
|
||||||
|
REDIRECT_URL = "http://testserver/redirect-url"
|
||||||
|
|
||||||
|
|
||||||
|
class DatatransServiceTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username=uuid.uuid4().hex,
|
||||||
|
email=uuid.uuid4().hex,
|
||||||
|
password="password",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(DATATRANS_BASIC_AUTH_KEY="BASIC_AUTH_KEY")
|
||||||
|
@patch("vbv_lernwelt.shop.services.requests.post")
|
||||||
|
@patch("vbv_lernwelt.shop.services.uuid.uuid4")
|
||||||
|
def test_init_transaction_201(self, mock_uuid, mock_post):
|
||||||
|
# GIVEN
|
||||||
|
mock_uuid.return_value = uuid.uuid4()
|
||||||
|
mock_post.return_value.status_code = 201
|
||||||
|
mock_post.return_value.json.return_value = {
|
||||||
|
"transactionId": 1234567890,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.user.language = "it"
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
transaction_id = init_transaction(
|
||||||
|
user=self.user,
|
||||||
|
amount_chf_centimes=324_30,
|
||||||
|
redirect_url_success=f"{REDIRECT_URL}/success",
|
||||||
|
redirect_url_error=f"{REDIRECT_URL}/error",
|
||||||
|
redirect_url_cancel=f"{REDIRECT_URL}/cancel",
|
||||||
|
webhook_url=f"{REDIRECT_URL}/webhook",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1234567890, transaction_id)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
url="https://api.sandbox.datatrans.com/v1/transactions",
|
||||||
|
json={
|
||||||
|
"autoSettle": True,
|
||||||
|
"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")
|
||||||
|
def test_init_transaction_500(self, mock_post):
|
||||||
|
# GIVEN
|
||||||
|
mock_post.return_value.status_code = 500
|
||||||
|
|
||||||
|
# WHEN / THEN
|
||||||
|
with self.assertRaises(InitTransactionException):
|
||||||
|
init_transaction(
|
||||||
|
user=self.user,
|
||||||
|
amount_chf_centimes=324_30,
|
||||||
|
redirect_url_success=f"/success",
|
||||||
|
redirect_url_error=f"/error",
|
||||||
|
redirect_url_cancel=f"/cancel",
|
||||||
|
webhook_url=f"/webhook",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_payment_url(self):
|
||||||
|
# GIVEN
|
||||||
|
transaction_id = "1234567890"
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
url = get_payment_url(transaction_id)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(
|
||||||
|
url,
|
||||||
|
f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id}",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from vbv_lernwelt.shop.services import is_signature_valid
|
||||||
|
|
||||||
|
|
||||||
|
class DatatransSigningTestCase(TestCase):
|
||||||
|
"""
|
||||||
|
Test based on the example from the docs.
|
||||||
|
Key is from their example, not ours!
|
||||||
|
"""
|
||||||
|
|
||||||
|
HMAC_KEY_FROM_THE_DOCS_NOT_HAZARDOUS = (
|
||||||
|
"861bbfc01e089259091927d6ad7f71c8"
|
||||||
|
"b46b7ee13499574e83c633b74cdc29e3"
|
||||||
|
"b7e262e41318c8425c520f146986675f"
|
||||||
|
"dd58a4531a01c99f06da378fdab0414a"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_signature_happy_ala_docs(self):
|
||||||
|
# GIVEN
|
||||||
|
payload = b"HELLO"
|
||||||
|
signature = "t=1605697463367,s0=82ef9a8178dcb4df0b71540fa06d7da826ecb26e1977e230bdc8c9d6f9f1af84"
|
||||||
|
|
||||||
|
# WHEN / THEN
|
||||||
|
self.assertTrue(
|
||||||
|
is_signature_valid(
|
||||||
|
hmac_key=self.HMAC_KEY_FROM_THE_DOCS_NOT_HAZARDOUS,
|
||||||
|
payload=payload,
|
||||||
|
signature=signature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_signature_not_happy(self):
|
||||||
|
# GIVEN
|
||||||
|
tampered_payload = b"HELLO=I=TAMPERED=WITH=PAYLOAD=HIHI=I=AM=EVIL"
|
||||||
|
signature = "t=1605697463367,s0=82ef9a8178dcb4df0b71540fa06d7da826ecb26e1977e230bdc8c9d6f9f1af84"
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertFalse(
|
||||||
|
is_signature_valid(
|
||||||
|
hmac_key=self.HMAC_KEY_FROM_THE_DOCS_NOT_HAZARDOUS,
|
||||||
|
payload=tampered_payload,
|
||||||
|
signature=signature,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,279 @@
|
||||||
|
from unittest.mock import ANY, patch
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.admin import User
|
||||||
|
from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN_ID
|
||||||
|
from vbv_lernwelt.course.creators.test_utils import create_course, create_course_session
|
||||||
|
from vbv_lernwelt.course.models import CourseSessionUser
|
||||||
|
from vbv_lernwelt.notify.email.email_services import EmailTemplate
|
||||||
|
from vbv_lernwelt.shop.const import VV_DE_PRODUCT_SKU
|
||||||
|
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState, Product
|
||||||
|
|
||||||
|
|
||||||
|
def create_checkout_information(
|
||||||
|
user: User,
|
||||||
|
transaction_id: str,
|
||||||
|
state: CheckoutState,
|
||||||
|
) -> CheckoutInformation:
|
||||||
|
return CheckoutInformation.objects.create(
|
||||||
|
user=user,
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
product_sku=VV_DE_PRODUCT_SKU,
|
||||||
|
product_price=324_30,
|
||||||
|
state=state.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DatatransWebhookTestCase(APITestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
course, _ = create_course(
|
||||||
|
title="VV_in_DE",
|
||||||
|
# needed for VV_DE_PRODUCT_SKU
|
||||||
|
_id=COURSE_VERSICHERUNGSVERMITTLERIN_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
create_course_session(course=course, title="Versicherungsvermittler/-in DE")
|
||||||
|
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="testuser",
|
||||||
|
email="test@user.com",
|
||||||
|
password="testpassword",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.product = Product.objects.create(
|
||||||
|
sku=VV_DE_PRODUCT_SKU,
|
||||||
|
price=324_30,
|
||||||
|
description="VV",
|
||||||
|
name="VV",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_webhook_unsigned_payload(self):
|
||||||
|
# GIVEN
|
||||||
|
payload = {
|
||||||
|
"transactionId": "1234567890",
|
||||||
|
"status": "settled",
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {"Datatrans-Signature": "t=1605697463367,s0=guessed"}
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.post(
|
||||||
|
path=reverse("shop-transaction-webhook"),
|
||||||
|
format="json",
|
||||||
|
data=payload,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(
|
||||||
|
response.json(),
|
||||||
|
{"status": "invalid signature"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("vbv_lernwelt.shop.views.is_signature_valid")
|
||||||
|
@patch("vbv_lernwelt.shop.views.send_email")
|
||||||
|
def test_webhook_settled_transmitted_paid(
|
||||||
|
self, mock_send_mail, mock_is_signature_valid
|
||||||
|
):
|
||||||
|
# GIVEN
|
||||||
|
transaction_id = "1234567890"
|
||||||
|
|
||||||
|
create_checkout_information(
|
||||||
|
user=self.user,
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
state=CheckoutState.ONGOING,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_is_signature_valid.return_value = True
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
|
||||||
|
# ~immediately after successful payment
|
||||||
|
response_settled = self.client.post(
|
||||||
|
path=reverse("shop-transaction-webhook"),
|
||||||
|
format="json",
|
||||||
|
headers={"Datatrans-Signature": "<signature>"},
|
||||||
|
data={
|
||||||
|
"status": "settled",
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ~24h later
|
||||||
|
response_transmitted = self.client.post(
|
||||||
|
path=reverse("shop-transaction-webhook"),
|
||||||
|
format="json",
|
||||||
|
headers={"Datatrans-Signature": "<signature>"},
|
||||||
|
data={
|
||||||
|
"status": "transmitted",
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(status.HTTP_200_OK, response_settled.status_code)
|
||||||
|
self.assertEqual(status.HTTP_200_OK, response_transmitted.status_code)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
CheckoutState.PAID.value,
|
||||||
|
CheckoutInformation.objects.get(transaction_id=transaction_id).state,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
CourseSessionUser.objects.count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.user,
|
||||||
|
CourseSessionUser.objects.first().user,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
COURSE_VERSICHERUNGSVERMITTLERIN_ID,
|
||||||
|
CourseSessionUser.objects.first().course_session.course.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
mock_send_mail.call_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_send_mail.assert_called_once_with(
|
||||||
|
template=EmailTemplate.WELCOME_MAIL_VV,
|
||||||
|
recipient_email=self.user.email,
|
||||||
|
template_data={
|
||||||
|
"course": "Versicherungsvermittler/-in (Deutsch)",
|
||||||
|
"target_url": "https://my.vbv-afa.ch/",
|
||||||
|
},
|
||||||
|
template_language=self.user.language,
|
||||||
|
fail_silently=ANY,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("vbv_lernwelt.shop.views.is_signature_valid")
|
||||||
|
def test_webhook_updates_webhook_history(self, mock_is_signature_valid):
|
||||||
|
# GIVEN
|
||||||
|
transaction_id = "1234567890"
|
||||||
|
|
||||||
|
create_checkout_information(
|
||||||
|
user=self.user,
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
state=CheckoutState.ONGOING,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_is_signature_valid.return_value = True
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response_1 = self.client.post(
|
||||||
|
path=reverse("shop-transaction-webhook"),
|
||||||
|
format="json",
|
||||||
|
headers={"Datatrans-Signature": "<signature>"},
|
||||||
|
data={
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"status": "failed",
|
||||||
|
"whatever": "1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response_2 = self.client.post(
|
||||||
|
path=reverse("shop-transaction-webhook"),
|
||||||
|
format="json",
|
||||||
|
headers={"Datatrans-Signature": "<signature>"},
|
||||||
|
data={
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"status": "failed",
|
||||||
|
"whatever": "2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response_1.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response_2.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
CheckoutInformation.objects.get(
|
||||||
|
transaction_id=transaction_id
|
||||||
|
).webhook_history,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"status": "failed",
|
||||||
|
"whatever": "1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"status": "failed",
|
||||||
|
"whatever": "2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("vbv_lernwelt.shop.views.is_signature_valid")
|
||||||
|
def test_webhook_failed(self, mock_is_signature_valid):
|
||||||
|
# GIVEN
|
||||||
|
transaction_id = "1234567890"
|
||||||
|
state_received = "failed"
|
||||||
|
|
||||||
|
create_checkout_information(
|
||||||
|
user=self.user,
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
state=CheckoutState.ONGOING,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_is_signature_valid.return_value = True
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.post(
|
||||||
|
path=reverse("shop-transaction-webhook"),
|
||||||
|
format="json",
|
||||||
|
headers={"Datatrans-Signature": "<signature>"},
|
||||||
|
data={
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"status": state_received,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
CheckoutInformation.objects.get(transaction_id=transaction_id).state,
|
||||||
|
CheckoutState.FAILED,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("vbv_lernwelt.shop.views.is_signature_valid")
|
||||||
|
def test_webhook_cancelled(self, mock_is_signature_valid):
|
||||||
|
# GIVEN
|
||||||
|
transaction_id = "1234567890"
|
||||||
|
state_received = "canceled"
|
||||||
|
|
||||||
|
create_checkout_information( # noqa
|
||||||
|
user=self.user,
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
state=CheckoutState.ONGOING,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_is_signature_valid.return_value = True
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.post(
|
||||||
|
path=reverse("shop-transaction-webhook"),
|
||||||
|
format="json",
|
||||||
|
headers={"Datatrans-Signature": "<signature>"},
|
||||||
|
data={
|
||||||
|
"transactionId": transaction_id,
|
||||||
|
"status": state_received,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
CheckoutInformation.objects.get(transaction_id=transaction_id).state,
|
||||||
|
CheckoutState.CANCELED,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
from datetime import date
|
||||||
|
from unittest.mock import create_autospec
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.admin import User
|
||||||
|
from vbv_lernwelt.shop.invoice.abacus import AbacusInvoiceCreator
|
||||||
|
from vbv_lernwelt.shop.invoice.creator import Item
|
||||||
|
from vbv_lernwelt.shop.invoice.repositories import InvoiceRepository
|
||||||
|
from vbv_lernwelt.shop.models import CheckoutInformation
|
||||||
|
|
||||||
|
USER_USERNAME = "testuser"
|
||||||
|
USER_EMAIL = "test@example.com"
|
||||||
|
USER_PASSWORD = "testpassword"
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceTestCase(TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username=USER_USERNAME,
|
||||||
|
email=USER_EMAIL,
|
||||||
|
password=USER_PASSWORD,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_render_invoice(self):
|
||||||
|
# GIVEN
|
||||||
|
creator = AbacusInvoiceCreator(repository=create_autospec(InvoiceRepository))
|
||||||
|
items = [Item(product_number="001", quantity=1, description="Test Item")]
|
||||||
|
customer_number = "12345"
|
||||||
|
order_date = date(2023, 1, 1)
|
||||||
|
reference_purchase_order = "PO12345678"
|
||||||
|
unic_id = "UNIC001"
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
invoice_xml = creator.invoice_xml(
|
||||||
|
customer_number,
|
||||||
|
order_date,
|
||||||
|
reference_purchase_order,
|
||||||
|
unic_id,
|
||||||
|
items,
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
assert "<CustomerNumber>12345</CustomerNumber>" in invoice_xml
|
||||||
|
assert "<ItemNumber>1</ItemNumber>" in invoice_xml
|
||||||
|
assert "<ProductNumber>001</ProductNumber>" in invoice_xml
|
||||||
|
assert "<QuantityOrdered>1</QuantityOrdered>" in invoice_xml
|
||||||
|
assert "<Text>Test Item</Text>" in invoice_xml
|
||||||
|
|
||||||
|
def test_create_invoice_calls_upload(self):
|
||||||
|
# GIVEN
|
||||||
|
repository_mock = create_autospec(InvoiceRepository)
|
||||||
|
|
||||||
|
creator = AbacusInvoiceCreator(repository=repository_mock)
|
||||||
|
|
||||||
|
expected_filename = "test.xml"
|
||||||
|
|
||||||
|
checkout_information = CheckoutInformation.objects.create(
|
||||||
|
user=self.user,
|
||||||
|
transaction_id="12345",
|
||||||
|
product_sku="001",
|
||||||
|
product_name="Test Product",
|
||||||
|
product_description="Test Product Description",
|
||||||
|
product_price=1000,
|
||||||
|
state="initialized",
|
||||||
|
)
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
creator.create_invoice(
|
||||||
|
checkout_information=checkout_information,
|
||||||
|
filename=expected_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
repository_mock.upload_invoice.assert_called_once()
|
||||||
|
uploaded_invoice, uploaded_filename = repository_mock.upload_invoice.call_args[
|
||||||
|
0
|
||||||
|
]
|
||||||
|
|
||||||
|
assert uploaded_filename == expected_filename
|
||||||
|
assert "<CustomerNumber>12345</CustomerNumber>" in uploaded_invoice
|
||||||
|
assert "<ItemNumber>1</ItemNumber>" in uploaded_invoice
|
||||||
|
assert "<ProductNumber>001</ProductNumber>" in uploaded_invoice
|
||||||
|
assert "<QuantityOrdered>1</QuantityOrdered>" in uploaded_invoice
|
||||||
|
assert "<Text>Test Product Description</Text>" in uploaded_invoice
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
|
||||||
|
from vbv_lernwelt.shop.views import (
|
||||||
|
checkout_vv,
|
||||||
|
get_billing_address,
|
||||||
|
transaction_webhook,
|
||||||
|
update_billing_address,
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("billing-address/", get_billing_address, name="get-billing-address"),
|
||||||
|
path(
|
||||||
|
"billing-address/update/", update_billing_address, name="update-billing-address"
|
||||||
|
),
|
||||||
|
path("vv/checkout/", checkout_vv, name="checkout-vv"),
|
||||||
|
path(
|
||||||
|
"transaction/webhook/",
|
||||||
|
django_view_authentication_exempt(transaction_webhook),
|
||||||
|
name="shop-transaction-webhook",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
import structlog
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
from vbv_lernwelt.course.consts import (
|
||||||
|
COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID,
|
||||||
|
COURSE_VERSICHERUNGSVERMITTLERIN_ID,
|
||||||
|
COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID,
|
||||||
|
)
|
||||||
|
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
|
||||||
|
from vbv_lernwelt.notify.email.email_services import EmailTemplate, send_email
|
||||||
|
from vbv_lernwelt.shop.const import (
|
||||||
|
VV_DE_PRODUCT_SKU,
|
||||||
|
VV_FR_PRODUCT_SKU,
|
||||||
|
VV_IT_PRODUCT_SKU,
|
||||||
|
)
|
||||||
|
from vbv_lernwelt.shop.models import (
|
||||||
|
BillingAddress,
|
||||||
|
CheckoutInformation,
|
||||||
|
CheckoutState,
|
||||||
|
Product,
|
||||||
|
)
|
||||||
|
from vbv_lernwelt.shop.serializers import BillingAddressSerializer
|
||||||
|
from vbv_lernwelt.shop.services import (
|
||||||
|
datatrans_state_to_checkout_state,
|
||||||
|
get_payment_url,
|
||||||
|
init_transaction,
|
||||||
|
InitTransactionException,
|
||||||
|
is_signature_valid,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
PRODUCT_SKU_TO_COURSE = {
|
||||||
|
VV_DE_PRODUCT_SKU: COURSE_VERSICHERUNGSVERMITTLERIN_ID,
|
||||||
|
VV_FR_PRODUCT_SKU: COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID,
|
||||||
|
VV_IT_PRODUCT_SKU: COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_billing_address(request):
|
||||||
|
try:
|
||||||
|
billing_address = BillingAddress.objects.get(user=request.user)
|
||||||
|
data = BillingAddressSerializer(billing_address).data
|
||||||
|
except BillingAddress.DoesNotExist:
|
||||||
|
data = BillingAddressSerializer().data
|
||||||
|
data["first_name"] = request.user.first_name # noqa
|
||||||
|
data["last_name"] = request.user.last_name # noqa
|
||||||
|
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["PUT"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def update_billing_address(request):
|
||||||
|
try:
|
||||||
|
billing_address = BillingAddress.objects.get(user=request.user)
|
||||||
|
except BillingAddress.DoesNotExist:
|
||||||
|
billing_address = None
|
||||||
|
|
||||||
|
serializer = BillingAddressSerializer(
|
||||||
|
billing_address, data=request.data, partial=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save(user=request.user)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
def transaction_webhook(request):
|
||||||
|
"""IMPORTANT: This is not called for timed out transactions!"""
|
||||||
|
|
||||||
|
logger.info("Webhook: Datatrans called transaction webhook", body=request.body)
|
||||||
|
|
||||||
|
if not is_signature_valid(
|
||||||
|
signature=request.headers.get("Datatrans-Signature", ""),
|
||||||
|
payload=request.body,
|
||||||
|
):
|
||||||
|
logger.warning("Datatrans Transaction Webhook: Invalid Signature -> Ignored")
|
||||||
|
return JsonResponse({"status": "invalid signature"}, status=400)
|
||||||
|
|
||||||
|
transaction = request.data
|
||||||
|
transaction_id = transaction["transactionId"]
|
||||||
|
|
||||||
|
# keep webhook history (for debugging)
|
||||||
|
checkout_info = CheckoutInformation.objects.get(transaction_id=transaction_id)
|
||||||
|
checkout_info.webhook_history.append(transaction)
|
||||||
|
checkout_info.save(update_fields=["webhook_history"])
|
||||||
|
|
||||||
|
# update checkout state
|
||||||
|
checkout_state = datatrans_state_to_checkout_state(transaction["status"])
|
||||||
|
update_checkout_state(checkout_info=checkout_info, state=checkout_state)
|
||||||
|
|
||||||
|
# handle paid
|
||||||
|
if checkout_state == CheckoutState.PAID:
|
||||||
|
create_vv_course_session_user(checkout_info=checkout_info)
|
||||||
|
|
||||||
|
return JsonResponse({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def checkout_vv(request):
|
||||||
|
"""
|
||||||
|
Check-out for the Versicherungsvermittler products (vv-de, vv-fr, vv-it)
|
||||||
|
|
||||||
|
IMPORTANT: Even if we have an already ONGOING checkout,
|
||||||
|
we create a new one! This might seem a bit unintuitive,
|
||||||
|
but it's the advised way to handle it by Datatrans:
|
||||||
|
|
||||||
|
"Fehlverhalten des User können fast gar nicht abgefangen werden,
|
||||||
|
wichtig wäre aus eurer Sicht das ihr immer einen neuen INIT
|
||||||
|
schickt, wenn der User im Checkout ist und zum Beispiel
|
||||||
|
auf «Bezahlen» klickt. Um zum Beispiel White-screens
|
||||||
|
bei Browser Back redirections zu vermeiden."
|
||||||
|
|
||||||
|
"""
|
||||||
|
sku = request.data["product"]
|
||||||
|
base_redirect_url = request.data["redirect_url"]
|
||||||
|
|
||||||
|
logger.info(f"Checkout requested: sku={sku}", user_id=request.user.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
product = Product.objects.get(sku=sku)
|
||||||
|
except Product.DoesNotExist:
|
||||||
|
return next_step_response(
|
||||||
|
url=checkout_error_url(
|
||||||
|
base_url=base_redirect_url,
|
||||||
|
product_sku=sku,
|
||||||
|
message=f"{sku}_product_sku_does_not_exist_needs_to_be_created_first",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
checkouts = CheckoutInformation.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
product_sku=sku,
|
||||||
|
)
|
||||||
|
|
||||||
|
# already paid successfully -> redirect to home
|
||||||
|
# any other case create a new checkout (see doc above)
|
||||||
|
if checkouts.filter(state=CheckoutState.PAID).exists():
|
||||||
|
return next_step_response(url="/")
|
||||||
|
|
||||||
|
try:
|
||||||
|
transaction_id = init_transaction(
|
||||||
|
user=request.user,
|
||||||
|
amount_chf_centimes=product.price,
|
||||||
|
redirect_url_success=checkout_success_url(
|
||||||
|
base_url=base_redirect_url, product_sku=sku
|
||||||
|
),
|
||||||
|
redirect_url_error=checkout_error_url(
|
||||||
|
base_url=base_redirect_url, product_sku=sku
|
||||||
|
),
|
||||||
|
redirect_url_cancel=checkout_cancel_url(base_redirect_url),
|
||||||
|
webhook_url=webhook_url(base_redirect_url),
|
||||||
|
)
|
||||||
|
except InitTransactionException as e:
|
||||||
|
if not settings.DEBUG:
|
||||||
|
capture_exception(e)
|
||||||
|
return next_step_response(
|
||||||
|
url=checkout_error_url(
|
||||||
|
base_url=base_redirect_url,
|
||||||
|
product_sku=sku,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
CheckoutInformation.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
state=CheckoutState.ONGOING,
|
||||||
|
transaction_id=transaction_id,
|
||||||
|
# product
|
||||||
|
product_sku=sku,
|
||||||
|
product_price=product.price,
|
||||||
|
product_name=product.name,
|
||||||
|
product_description=product.description,
|
||||||
|
# address
|
||||||
|
**request.data["address"],
|
||||||
|
)
|
||||||
|
|
||||||
|
return next_step_response(url=get_payment_url(transaction_id))
|
||||||
|
|
||||||
|
|
||||||
|
def update_checkout_state(checkout_info: CheckoutInformation, state: CheckoutState):
|
||||||
|
checkout_info.state = state.value
|
||||||
|
checkout_info.save(update_fields=["state"])
|
||||||
|
|
||||||
|
|
||||||
|
def send_vv_welcome_email(checkout_info: CheckoutInformation):
|
||||||
|
course_names = {
|
||||||
|
VV_DE_PRODUCT_SKU: "Versicherungsvermittler/-in (Deutsch)",
|
||||||
|
VV_FR_PRODUCT_SKU: "Intermédiaire d’assurance (Français)",
|
||||||
|
VV_IT_PRODUCT_SKU: "Intermediario/a assicurativo/a (Italiano)",
|
||||||
|
}
|
||||||
|
|
||||||
|
send_email(
|
||||||
|
recipient_email=checkout_info.user.email,
|
||||||
|
template=EmailTemplate.WELCOME_MAIL_VV,
|
||||||
|
template_data={
|
||||||
|
"course": course_names[checkout_info.product_sku],
|
||||||
|
"target_url": "https://my.vbv-afa.ch/",
|
||||||
|
},
|
||||||
|
template_language=checkout_info.user.language,
|
||||||
|
fail_silently=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_vv_course_session_user(checkout_info: CheckoutInformation):
|
||||||
|
logger.info("Creating VV course session user", user_id=checkout_info.user_id)
|
||||||
|
|
||||||
|
_, created = CourseSessionUser.objects.get_or_create(
|
||||||
|
user=checkout_info.user,
|
||||||
|
role=CourseSessionUser.Role.MEMBER,
|
||||||
|
course_session=CourseSession.objects.filter(
|
||||||
|
course_id=PRODUCT_SKU_TO_COURSE[checkout_info.product_sku]
|
||||||
|
).first(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
logger.info("VV course session user created", user_id=checkout_info.user_id)
|
||||||
|
send_vv_welcome_email(checkout_info)
|
||||||
|
|
||||||
|
|
||||||
|
def next_step_response(
|
||||||
|
url: str,
|
||||||
|
) -> JsonResponse:
|
||||||
|
return JsonResponse(
|
||||||
|
{
|
||||||
|
"next_step_url": url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def webhook_url(base_url: str) -> str:
|
||||||
|
return f"{base_url}/api/shop/transaction/webhook/"
|
||||||
|
|
||||||
|
|
||||||
|
def checkout_error_url(
|
||||||
|
base_url: str, product_sku: str, message: str | None = None
|
||||||
|
) -> str:
|
||||||
|
url = f"{base_url}/onboarding/{product_sku}/checkout/address?error"
|
||||||
|
|
||||||
|
if message:
|
||||||
|
url += f"&message={message}"
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def checkout_cancel_url(base_url: str) -> str:
|
||||||
|
return f"{base_url}/"
|
||||||
|
|
||||||
|
|
||||||
|
def checkout_success_url(product_sku: str, base_url: str = "") -> str:
|
||||||
|
return f"{base_url}/onboarding/{product_sku}/checkout/complete"
|
||||||
|
|
@ -1,15 +1,5 @@
|
||||||
from authlib.integrations.django_client import OAuth
|
from authlib.integrations.django_client import OAuth
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
# # https://docs.authlib.org/en/latest/client/frameworks.html#frameworks-clients
|
|
||||||
oauth = OAuth()
|
oauth = OAuth()
|
||||||
oauth.register(
|
oauth.register(name="signup")
|
||||||
name=settings.OAUTH["client_name"],
|
oauth.register(name="signin")
|
||||||
client_id=settings.OAUTH["client_id"],
|
|
||||||
client_secret=settings.OAUTH["client_secret"],
|
|
||||||
request_token_url=None,
|
|
||||||
request_token_params=None,
|
|
||||||
authorize_params=settings.OAUTH["authorize_params"],
|
|
||||||
client_kwargs=settings.OAUTH["client_kwargs"],
|
|
||||||
server_metadata_url=settings.OAUTH["server_metadata_url"],
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,356 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from unittest.mock import ANY, MagicMock, patch
|
||||||
|
|
||||||
|
from authlib.integrations.base_client import OAuthError
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.test import override_settings, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.models import User
|
||||||
|
from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN_ID
|
||||||
|
from vbv_lernwelt.course.creators.test_utils import (
|
||||||
|
add_course_session_user,
|
||||||
|
create_course,
|
||||||
|
create_course_session,
|
||||||
|
)
|
||||||
|
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
|
||||||
|
from vbv_lernwelt.importer.services import create_or_update_user
|
||||||
|
|
||||||
|
|
||||||
|
def decoded_token(email, oid=None, given_name="Bobby", family_name="Table"):
|
||||||
|
return {
|
||||||
|
"email": email,
|
||||||
|
"oid": oid or uuid.uuid4(),
|
||||||
|
"given_name": given_name,
|
||||||
|
"family_name": family_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignInAuthorizeSSO(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
CourseSession.objects.all().delete()
|
||||||
|
User.objects.all().delete()
|
||||||
|
|
||||||
|
@override_settings(OAUTH={"client_name": "mock"})
|
||||||
|
@patch("vbv_lernwelt.sso.views.oauth")
|
||||||
|
@patch("vbv_lernwelt.sso.views.decode_jwt")
|
||||||
|
def test_authorize_first_time_uk(self, mock_decode_jwt, mock_oauth):
|
||||||
|
# GIVEN
|
||||||
|
email = "user@example.com"
|
||||||
|
token = decoded_token(email)
|
||||||
|
|
||||||
|
mock_decode_jwt.return_value = token
|
||||||
|
mock_oauth.signin.authorize_access_token.return_value = {
|
||||||
|
"id_token": "<some-token-irrelevant-for-this-test>"
|
||||||
|
}
|
||||||
|
|
||||||
|
state = base64.urlsafe_b64encode(
|
||||||
|
json.dumps({"course": "uk", "next": "/shall-be-ignored"}).encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.get(reverse("sso:authorize"), {"state": state})
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(302, response.status_code)
|
||||||
|
self.assertEqual("/onboarding/uk/account/create", response.url) # noqa
|
||||||
|
|
||||||
|
user = User.objects.get(email=email) # noqa
|
||||||
|
self.assertIsNotNone(user)
|
||||||
|
|
||||||
|
self.assertEqual(user.email, email)
|
||||||
|
self.assertEqual(user.first_name, token["given_name"])
|
||||||
|
self.assertEqual(user.last_name, token["family_name"])
|
||||||
|
self.assertEqual(user.sso_id, token["oid"])
|
||||||
|
|
||||||
|
@override_settings(OAUTH={"client_name": "mock"})
|
||||||
|
@patch("vbv_lernwelt.sso.views.oauth")
|
||||||
|
@patch("vbv_lernwelt.sso.views.decode_jwt")
|
||||||
|
def test_authorize_first_time_vv(self, mock_decode_jwt, mock_oauth):
|
||||||
|
# GIVEN
|
||||||
|
email = "user@example.com"
|
||||||
|
token = decoded_token(email)
|
||||||
|
|
||||||
|
mock_decode_jwt.return_value = token
|
||||||
|
mock_oauth.signin.authorize_access_token.return_value = {
|
||||||
|
"id_token": "<some-token-irrelevant-for-this-test>"
|
||||||
|
}
|
||||||
|
|
||||||
|
state = base64.urlsafe_b64encode(
|
||||||
|
json.dumps({"course": "vv-de", "next": "/shall-be-ignored"}).encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.get(reverse("sso:authorize"), {"state": state})
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(302, response.status_code)
|
||||||
|
self.assertEqual("/onboarding/vv-de/account/create", response.url) # noqa
|
||||||
|
|
||||||
|
user = User.objects.get(email=email) # noqa
|
||||||
|
self.assertIsNotNone(user)
|
||||||
|
|
||||||
|
self.assertEqual(user.email, email)
|
||||||
|
self.assertEqual(user.first_name, token["given_name"])
|
||||||
|
self.assertEqual(user.last_name, token["family_name"])
|
||||||
|
self.assertEqual(user.sso_id, token["oid"])
|
||||||
|
|
||||||
|
@patch("vbv_lernwelt.sso.views.oauth")
|
||||||
|
def test_authorize_on_tampered_token(self, mock_oauth_service):
|
||||||
|
# GIVEN
|
||||||
|
mock_oauth_service.signin.authorize_access_token.side_effect = OAuthError()
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
response = self.client.get(reverse("sso:authorize"))
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(302, response.status_code)
|
||||||
|
self.assertEqual("/", response.url) # noqa
|
||||||
|
|
||||||
|
@override_settings(OAUTH={"client_name": "mock"})
|
||||||
|
@patch("vbv_lernwelt.sso.views.oauth")
|
||||||
|
@patch("vbv_lernwelt.sso.views.decode_jwt")
|
||||||
|
def test_authorize_onboarded_uk(self, mock_decode_jwt, mock_oauth):
|
||||||
|
# GIVEN
|
||||||
|
email = "some@email.com"
|
||||||
|
token = decoded_token(email)
|
||||||
|
mock_decode_jwt.return_value = token
|
||||||
|
mock_oauth.signin.authorize_access_token.return_value = {
|
||||||
|
"id_token": "<some-token-irrelevant-for-this-test>"
|
||||||
|
}
|
||||||
|
|
||||||
|
# create a user that is already onboarded for UK
|
||||||
|
user = create_or_update_user(
|
||||||
|
email=email,
|
||||||
|
sso_id=str(token["oid"]),
|
||||||
|
first_name=token["given_name"],
|
||||||
|
last_name=token["family_name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
course, _ = create_course("uk")
|
||||||
|
course_session = create_course_session(course, "UK", "2023")
|
||||||
|
add_course_session_user(course_session, user, CourseSessionUser.Role.MEMBER)
|
||||||
|
|
||||||
|
self.assertIsNotNone(User.objects.get(email=email))
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
state = base64.urlsafe_b64encode(json.dumps({"course": "uk"}).encode()).decode()
|
||||||
|
response = self.client.get(reverse("sso:authorize"), {"state": state})
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(302, response.status_code)
|
||||||
|
self.assertEqual("/", response.url) # noqa
|
||||||
|
|
||||||
|
@override_settings(OAUTH={"client_name": "mock"})
|
||||||
|
@patch("vbv_lernwelt.sso.views.oauth")
|
||||||
|
@patch("vbv_lernwelt.sso.views.decode_jwt")
|
||||||
|
def test_authorize_onboarded_vv(self, mock_decode_jwt, mock_oauth):
|
||||||
|
# GIVEN
|
||||||
|
email = "some@email.com"
|
||||||
|
token = decoded_token(email)
|
||||||
|
mock_decode_jwt.return_value = token
|
||||||
|
mock_oauth.signin.authorize_access_token.return_value = {
|
||||||
|
"id_token": "<some-token-irrelevant-for-this-test>"
|
||||||
|
}
|
||||||
|
|
||||||
|
# create a user that is already onboarded for UK
|
||||||
|
user = create_or_update_user(
|
||||||
|
email=email,
|
||||||
|
sso_id=str(token["oid"]),
|
||||||
|
first_name=token["given_name"],
|
||||||
|
last_name=token["family_name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
course, _ = create_course(_id=COURSE_VERSICHERUNGSVERMITTLERIN_ID, title="VV")
|
||||||
|
course_session = create_course_session(course, "VV", "VV")
|
||||||
|
add_course_session_user(course_session, user, CourseSessionUser.Role.MEMBER)
|
||||||
|
|
||||||
|
self.assertIsNotNone(User.objects.get(email=email))
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
state = base64.urlsafe_b64encode(json.dumps({"course": "vv"}).encode()).decode()
|
||||||
|
response = self.client.get(reverse("sso:authorize"), {"state": state})
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(302, response.status_code)
|
||||||
|
self.assertEqual("/", response.url) # noqa
|
||||||
|
|
||||||
|
@override_settings(OAUTH={"client_name": "mock"})
|
||||||
|
@patch("vbv_lernwelt.sso.views.oauth")
|
||||||
|
@patch("vbv_lernwelt.sso.views.decode_jwt")
|
||||||
|
def test_authorize_next_url(self, mock_decode_jwt, mock_oauth):
|
||||||
|
# GIVEN
|
||||||
|
next_url = "/some/next/url"
|
||||||
|
|
||||||
|
mock_decode_jwt.return_value = decoded_token("whatever@example.com")
|
||||||
|
mock_oauth.signin.authorize_access_token.return_value = {
|
||||||
|
"id_token": "<some-token-irrelevant-for-this-test>"
|
||||||
|
}
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
state = base64.urlsafe_b64encode(
|
||||||
|
json.dumps({"next": next_url}).encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("sso:authorize"), {"state": state})
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
self.assertEqual(302, response.status_code)
|
||||||
|
self.assertEqual(next_url, response.url) # noqa
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignIn(TestCase):
|
||||||
|
@override_settings(OAUTH_SIGNIN_REDIRECT_URI="/sso/callback")
|
||||||
|
@patch("vbv_lernwelt.sso.views.oauth")
|
||||||
|
def test_signin_with_course_param(self, mock_oauth):
|
||||||
|
# GIVEN
|
||||||
|
course_param = "vv-de"
|
||||||
|
|
||||||
|
expected_state = {"course": course_param, "next": None}
|
||||||
|
expected_state_encoded = base64.urlsafe_b64encode(
|
||||||
|
json.dumps(expected_state).encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
mock_oauth.signin.authorize_redirect = MagicMock()
|
||||||
|
mock_oauth.signin.authorize_redirect.return_value = redirect(
|
||||||
|
"/just/here/to/return/a/redirect/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
self.client.get(
|
||||||
|
reverse("sso:login"),
|
||||||
|
{"course": course_param},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
mock_oauth.signin.authorize_redirect.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
"/sso/callback",
|
||||||
|
state=expected_state_encoded,
|
||||||
|
ui_locales="de",
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(OAUTH_SIGNIN_REDIRECT_URI="/sso/callback")
|
||||||
|
@patch("vbv_lernwelt.sso.views.oauth")
|
||||||
|
def test_signin_with_state_param(self, mock_oauth):
|
||||||
|
# GIVEN
|
||||||
|
course = "vv-de"
|
||||||
|
|
||||||
|
state_param = base64.urlsafe_b64encode(
|
||||||
|
json.dumps({"course": course, "next": None}).encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
expected_state = {"course": course, "next": None}
|
||||||
|
expected_state_encoded = base64.urlsafe_b64encode(
|
||||||
|
json.dumps(expected_state).encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
mock_oauth.signin.authorize_redirect = MagicMock()
|
||||||
|
mock_oauth.signin.authorize_redirect.return_value = redirect(
|
||||||
|
"/just/here/to/return/a/redirect/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
self.client.get(
|
||||||
|
reverse("sso:login"),
|
||||||
|
{"state": state_param},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
mock_oauth.signin.authorize_redirect.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
"/sso/callback",
|
||||||
|
state=expected_state_encoded,
|
||||||
|
ui_locales="de",
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(OAUTH_SIGNIN_REDIRECT_URI="/sso/callback")
|
||||||
|
@patch("vbv_lernwelt.sso.views.oauth")
|
||||||
|
def test_signin_next_url(self, mock_oauth):
|
||||||
|
# GIVEN
|
||||||
|
next_url = "/some/next/url"
|
||||||
|
|
||||||
|
expected_state = {"course": None, "next": next_url}
|
||||||
|
expected_state_encoded = base64.urlsafe_b64encode(
|
||||||
|
json.dumps(expected_state).encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
mock_oauth.signin.authorize_redirect = MagicMock()
|
||||||
|
mock_oauth.signin.authorize_redirect.return_value = redirect(
|
||||||
|
"/just/here/to/return/a/redirect/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
self.client.get(
|
||||||
|
reverse("sso:login"),
|
||||||
|
{"next": next_url},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
mock_oauth.signin.authorize_redirect.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
"/sso/callback",
|
||||||
|
state=expected_state_encoded,
|
||||||
|
ui_locales=ANY,
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(OAUTH_SIGNIN_REDIRECT_URI="/sso/callback")
|
||||||
|
@patch("vbv_lernwelt.sso.views.oauth")
|
||||||
|
def test_signin_language(self, mock_oauth):
|
||||||
|
# GIVEN
|
||||||
|
language = "fr"
|
||||||
|
|
||||||
|
mock_oauth.signin.authorize_redirect = MagicMock()
|
||||||
|
mock_oauth.signin.authorize_redirect.return_value = redirect(
|
||||||
|
"/just/here/to/return/a/redirect/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
self.client.get(
|
||||||
|
reverse("sso:login"),
|
||||||
|
{"lang": language},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
mock_oauth.signin.authorize_redirect.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
"/sso/callback",
|
||||||
|
state=ANY,
|
||||||
|
ui_locales=language,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSignUp(TestCase):
|
||||||
|
@override_settings(OAUTH_SIGNUP_REDIRECT_URI="/sso/login")
|
||||||
|
@patch("vbv_lernwelt.sso.views.oauth")
|
||||||
|
def test_signup_with_course_param(self, mock_oauth):
|
||||||
|
# GIVEN
|
||||||
|
course_param = "vv-de"
|
||||||
|
next_param = "/some-next-url"
|
||||||
|
language = "fr"
|
||||||
|
|
||||||
|
expected_state = {"course": course_param, "next": next_param}
|
||||||
|
expected_state_encoded = base64.urlsafe_b64encode(
|
||||||
|
json.dumps(expected_state).encode()
|
||||||
|
).decode()
|
||||||
|
|
||||||
|
mock_oauth.signup.authorize_redirect = MagicMock()
|
||||||
|
mock_oauth.signup.authorize_redirect.return_value = redirect(
|
||||||
|
"/just/here/to/return/a/redirect/object"
|
||||||
|
)
|
||||||
|
|
||||||
|
# WHEN
|
||||||
|
self.client.get(
|
||||||
|
reverse("sso:signup"),
|
||||||
|
{"course": course_param, "next": next_param, "lang": language},
|
||||||
|
)
|
||||||
|
|
||||||
|
# THEN
|
||||||
|
mock_oauth.signup.authorize_redirect.assert_called_once_with(
|
||||||
|
ANY,
|
||||||
|
"/sso/login",
|
||||||
|
state=expected_state_encoded,
|
||||||
|
lang=language,
|
||||||
|
)
|
||||||
|
|
@ -6,10 +6,11 @@ from . import views
|
||||||
|
|
||||||
app_name = "sso"
|
app_name = "sso"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(r"login/", django_view_authentication_exempt(views.login), name="login"),
|
path(r"login/", django_view_authentication_exempt(views.signin), name="login"),
|
||||||
|
path(r"signup/", django_view_authentication_exempt(views.signup), name="signup"),
|
||||||
path(
|
path(
|
||||||
r"callback/",
|
r"callback/",
|
||||||
django_view_authentication_exempt(views.authorize),
|
django_view_authentication_exempt(views.authorize_signin),
|
||||||
name="authorize",
|
name="authorize",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
|
||||||
import structlog as structlog
|
import structlog as structlog
|
||||||
from authlib.integrations.base_client import OAuthError
|
from authlib.integrations.base_client import OAuthError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -5,53 +8,116 @@ from django.contrib.auth import login as dj_login
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from sentry_sdk import capture_exception
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
from vbv_lernwelt.core.models import User
|
||||||
|
from vbv_lernwelt.course.models import CourseSession
|
||||||
|
from vbv_lernwelt.course_session.utils import has_course_session_user_vv
|
||||||
from vbv_lernwelt.importer.services import create_or_update_user
|
from vbv_lernwelt.importer.services import create_or_update_user
|
||||||
from vbv_lernwelt.sso.client import oauth
|
from vbv_lernwelt.sso.client import oauth
|
||||||
from vbv_lernwelt.sso.jwt import decode_jwt
|
from vbv_lernwelt.sso.jwt import decode_jwt
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
OAUTH_FAIL_REDIRECT = "login-error"
|
|
||||||
|
def signup(request):
|
||||||
|
course_param = request.GET.get("course")
|
||||||
|
next_param = request.GET.get("next")
|
||||||
|
|
||||||
|
state_json = json.dumps({"course": course_param, "next": next_param})
|
||||||
|
state_encoded = base64.urlsafe_b64encode(state_json.encode()).decode()
|
||||||
|
|
||||||
|
redirect_uri = settings.OAUTH_SIGNUP_REDIRECT_URI
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"SSO Signup (course={course_param}, next={next_param})",
|
||||||
|
sso_signup_redirect_uri=redirect_uri,
|
||||||
|
)
|
||||||
|
|
||||||
|
return oauth.signup.authorize_redirect(
|
||||||
|
request, redirect_uri, state=state_encoded, lang=request.GET.get("lang", "de")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def login(request):
|
def signin(request):
|
||||||
oauth_client = oauth.create_client(settings.OAUTH["client_name"])
|
"""
|
||||||
redirect_uri = settings.OAUTH["local_redirect_uri"]
|
Called directly from the frontend AND as a redirect from signup!
|
||||||
language = request.GET.get("lang", "de")
|
"""
|
||||||
return oauth_client.authorize_redirect(request, redirect_uri, lang=language)
|
|
||||||
|
|
||||||
|
# redirect from signup
|
||||||
def authorize(request):
|
if "state" in request.GET:
|
||||||
try:
|
state_decoded = json.loads(
|
||||||
logger.debug(request, label="sso")
|
base64.urlsafe_b64decode(request.GET.get("state").encode()).decode()
|
||||||
token = getattr(oauth, settings.OAUTH["client_name"]).authorize_access_token(
|
|
||||||
request
|
|
||||||
)
|
)
|
||||||
decoded_token = decode_jwt(token["id_token"])
|
state_course_param = state_decoded.get("course")
|
||||||
# logger.debug(label="sso", decoded_token=decoded_token)
|
state_next_param = state_decoded.get("next")
|
||||||
|
else:
|
||||||
|
state_course_param = None
|
||||||
|
state_next_param = None
|
||||||
|
|
||||||
|
course_param = request.GET.get("course", state_course_param)
|
||||||
|
next_param = request.GET.get("next", state_next_param)
|
||||||
|
|
||||||
|
state_json = json.dumps({"course": course_param, "next": next_param})
|
||||||
|
state_encoded = base64.urlsafe_b64encode(state_json.encode()).decode()
|
||||||
|
|
||||||
|
redirect_uri = settings.OAUTH_SIGNIN_REDIRECT_URI
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"SSO Login (course={course_param}, next={next_param})",
|
||||||
|
sso_login_redirect_uri=redirect_uri,
|
||||||
|
)
|
||||||
|
|
||||||
|
return oauth.signin.authorize_redirect(
|
||||||
|
request,
|
||||||
|
redirect_uri,
|
||||||
|
state=state_encoded,
|
||||||
|
ui_locales=request.GET.get("lang", "de"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_redirect_uri(user: User, course: str | None, next_url: str | None):
|
||||||
|
if course and course.startswith("vv") and not has_course_session_user_vv(user):
|
||||||
|
return redirect(f"/onboarding/{course}/account/create")
|
||||||
|
elif (
|
||||||
|
course == "uk"
|
||||||
|
and not CourseSession.objects.filter(coursesessionuser__user=user).exists()
|
||||||
|
):
|
||||||
|
return redirect("/onboarding/uk/account/create")
|
||||||
|
elif next_url:
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
def authorize_signin(request):
|
||||||
|
try:
|
||||||
|
jwt_token = oauth.signin.authorize_access_token(request)
|
||||||
except OAuthError as e:
|
except OAuthError as e:
|
||||||
logger.error(e, exc_info=True, label="sso")
|
logger.error(e, exc_info=True, label="sso")
|
||||||
if not settings.DEBUG:
|
if not settings.DEBUG:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
return redirect(f"/{OAUTH_FAIL_REDIRECT}?state=someerror") # to be defined
|
return redirect("/")
|
||||||
|
|
||||||
|
id_token = decode_jwt(jwt_token["id_token"])
|
||||||
|
|
||||||
|
state = json.loads(
|
||||||
|
base64.urlsafe_b64decode(request.GET.get("state").encode()).decode()
|
||||||
|
)
|
||||||
|
|
||||||
|
course = state.get("course")
|
||||||
|
next_url = state.get("next")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"SSO Authorize (course={course}, next={next_url}",
|
||||||
|
sso_authorize_id_token=id_token,
|
||||||
|
)
|
||||||
|
|
||||||
user_data = _user_data_from_token_data(decoded_token)
|
|
||||||
user = create_or_update_user(
|
user = create_or_update_user(
|
||||||
email=user_data.get("email").lower(),
|
email=id_token.get("email", ""),
|
||||||
sso_id=user_data.get("sso_id"),
|
sso_id=id_token.get("oid"),
|
||||||
first_name=user_data.get("first_name", ""),
|
first_name=id_token.get("given_name", ""),
|
||||||
last_name=user_data.get("last_name", ""),
|
last_name=id_token.get("family_name", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
dj_login(request, user)
|
dj_login(request, user)
|
||||||
return redirect(f"/")
|
|
||||||
|
|
||||||
|
return get_redirect_uri(user=user, course=course, next_url=next_url)
|
||||||
def _user_data_from_token_data(token: dict) -> dict:
|
|
||||||
first_email = token.get("emails", [""])[0]
|
|
||||||
return {
|
|
||||||
"first_name": token.get("given_name", ""),
|
|
||||||
"last_name": token.get("family_name", ""),
|
|
||||||
"email": first_email,
|
|
||||||
"sso_id": token.get("oid"),
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
<svg viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M7.99997 0.500065C5.87826 0.500065 3.84354 1.34291 2.34313 2.84319C0.842877 4.34358 0 6.37855 0 8.50003C0 10.6215 0.842844 12.6565 2.34313 14.1569C3.84351 15.6571 5.87849 16.5 7.99997 16.5C10.1214 16.5 12.1564 15.6572 13.6568 14.1569C15.1571 12.6565 15.9999 10.6215 15.9999 8.50003C15.9999 7.09568 15.6303 5.71622 14.9281 4.50002C14.226 3.28382 13.2161 2.27398 11.9999 1.57181C10.7837 0.869634 9.40425 0.5 7.9999 0.5L7.99997 0.500065ZM14.2315 5.64294H11.5087C11.1104 4.2353 10.4191 2.92763 9.48007 1.80587C10.514 2.03661 11.4806 2.50308 12.3047 3.16848C13.1288 3.83405 13.7883 4.68085 14.2315 5.64298V5.64294ZM7.96862 15.0885C6.92078 14.0448 6.13802 12.7653 5.68571 11.3573H10.3143C9.8611 12.7657 9.07743 14.0451 8.02857 15.0885C8.01059 15.1014 7.98648 15.1014 7.96862 15.0885H7.96862ZM5.39718 10.2144C5.18761 9.0811 5.18761 7.9191 5.39718 6.78581H10.6028C10.8123 7.9191 10.8123 9.0811 10.6028 10.2144H5.39718ZM1.1428 8.5001C1.1419 7.92178 1.21486 7.34564 1.35989 6.78581H4.23706C4.05058 7.92103 4.05058 9.07917 4.23706 10.2144H1.35989C1.21486 9.65455 1.1419 9.07842 1.1428 8.5001ZM7.99997 1.90023C8.01004 1.89628 8.02127 1.89628 8.03135 1.90023C9.08072 2.94729 9.86361 4.23086 10.3143 5.64294H5.68567C6.13886 4.23451 6.92254 2.95506 7.97139 1.91166C7.97917 1.90439 7.98938 1.90031 7.99997 1.90018L7.99997 1.90023ZM11.7629 6.78581H14.6401C14.9296 7.91032 14.9296 9.08988 14.6401 10.2144H11.7629C11.9494 9.07917 11.9494 7.92103 11.7629 6.78581ZM6.52012 1.80587C5.5821 2.92832 4.89089 4.23572 4.49154 5.64294H1.76869C2.21193 4.68082 2.87139 3.83402 3.69549 3.16845C4.51959 2.50301 5.48619 2.03656 6.52012 1.80583V1.80587ZM1.76876 11.3573H4.49161C4.88982 12.7649 5.58115 14.0726 6.52019 15.1943C5.48626 14.9636 4.51966 14.4971 3.69556 13.8317C2.87146 13.1661 2.21199 12.3193 1.76876 11.3572V11.3573ZM9.48308 15.1943C10.4199 14.0716 11.1101 12.7642 11.5087 11.3573H14.2316C13.7883 12.3194 13.1289 13.1662 12.3048 13.8317C11.4807 14.4972 10.5141 14.9636 9.48014 15.1944L9.48308 15.1943Z" fill="#0A0A0A"/>
|
<path d="M7.99997 0.500065C5.87826 0.500065 3.84354 1.34291 2.34313 2.84319C0.842877 4.34358 0 6.37855 0 8.50003C0 10.6215 0.842844 12.6565 2.34313 14.1569C3.84351 15.6571 5.87849 16.5 7.99997 16.5C10.1214 16.5 12.1564 15.6572 13.6568 14.1569C15.1571 12.6565 15.9999 10.6215 15.9999 8.50003C15.9999 7.09568 15.6303 5.71622 14.9281 4.50002C14.226 3.28382 13.2161 2.27398 11.9999 1.57181C10.7837 0.869634 9.40425 0.5 7.9999 0.5L7.99997 0.500065ZM14.2315 5.64294H11.5087C11.1104 4.2353 10.4191 2.92763 9.48007 1.80587C10.514 2.03661 11.4806 2.50308 12.3047 3.16848C13.1288 3.83405 13.7883 4.68085 14.2315 5.64298V5.64294ZM7.96862 15.0885C6.92078 14.0448 6.13802 12.7653 5.68571 11.3573H10.3143C9.8611 12.7657 9.07743 14.0451 8.02857 15.0885C8.01059 15.1014 7.98648 15.1014 7.96862 15.0885H7.96862ZM5.39718 10.2144C5.18761 9.0811 5.18761 7.9191 5.39718 6.78581H10.6028C10.8123 7.9191 10.8123 9.0811 10.6028 10.2144H5.39718ZM1.1428 8.5001C1.1419 7.92178 1.21486 7.34564 1.35989 6.78581H4.23706C4.05058 7.92103 4.05058 9.07917 4.23706 10.2144H1.35989C1.21486 9.65455 1.1419 9.07842 1.1428 8.5001ZM7.99997 1.90023C8.01004 1.89628 8.02127 1.89628 8.03135 1.90023C9.08072 2.94729 9.86361 4.23086 10.3143 5.64294H5.68567C6.13886 4.23451 6.92254 2.95506 7.97139 1.91166C7.97917 1.90439 7.98938 1.90031 7.99997 1.90018L7.99997 1.90023ZM11.7629 6.78581H14.6401C14.9296 7.91032 14.9296 9.08988 14.6401 10.2144H11.7629C11.9494 9.07917 11.9494 7.92103 11.7629 6.78581ZM6.52012 1.80587C5.5821 2.92832 4.89089 4.23572 4.49154 5.64294H1.76869C2.21193 4.68082 2.87139 3.83402 3.69549 3.16845C4.51959 2.50301 5.48619 2.03656 6.52012 1.80583V1.80587ZM1.76876 11.3573H4.49161C4.88982 12.7649 5.58115 14.0726 6.52019 15.1943C5.48626 14.9636 4.51966 14.4971 3.69556 13.8317C2.87146 13.1661 2.21199 12.3193 1.76876 11.3572V11.3573ZM9.48308 15.1943C10.4199 14.0716 11.1101 12.7642 11.5087 11.3573H14.2316C13.7883 12.3194 13.1289 13.1662 12.3048 13.8317C11.4807 14.4972 10.5141 14.9636 9.48014 15.1944L9.48308 15.1943Z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
|
@ -0,0 +1,5 @@
|
||||||
|
<svg viewBox="0 0 409 202" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M0.114263 201.863L0.101562 196.988C2.33496 196.982 54.8204 196.841 55.8238 196.841C65.4971 196.841 76.6661 194.543 84.2774 190.988C101.236 183.066 110.686 167.54 120.69 151.103C135.026 127.548 149.85 103.193 188.16 103.193C194.208 103.193 199.64 103.3 204.502 103.531C208.104 100.723 212.886 98.8203 218.595 98.0698C221.383 97.7036 225.217 96.9643 228.082 95.5058C227.509 95.2807 226.939 95.0522 226.375 94.8193C212.505 89.1123 202.784 81.7441 201.006 75.5908C199.839 71.5737 200.806 60.5307 200.92 59.2851C201.036 58.0297 202.088 57.0693 203.348 57.0693C229.683 57.0693 234.675 52.3359 234.675 43.6005C234.675 35.1371 227.481 33.8734 223.192 33.8734C215.798 33.8734 205.574 37.8797 200.338 45.537C199.937 46.1244 199.298 46.5072 198.59 46.5844C197.888 46.664 197.176 46.4257 196.658 45.9384L177.728 28.1669C176.834 27.3275 176.702 25.9545 177.421 24.9608C188.727 9.3311 206.945 0 226.154 0C248.48 0 271.082 14.0137 271.082 40.7969C271.082 61.4097 257.454 77.2012 236.94 80.6983V90.4063C236.94 91.3272 236.819 92.2989 236.512 93.2803C248.977 97.3159 264.505 100.218 281.772 100.218H408.926V105.093H281.772C265.039 105.093 248.27 102.441 233.711 97.5513C231.132 99.9077 226.678 101.925 219.231 102.904C217.198 103.171 215.319 103.599 213.606 104.174C233.502 106.134 240.829 111.233 240.829 121.269C240.829 132.634 232.226 139.978 218.913 139.978C206.419 139.978 196.997 130.965 196.997 119.014C196.997 114.961 198.087 111.322 200.113 108.246C196.501 108.129 192.53 108.068 188.16 108.068C152.59 108.068 139.118 130.203 124.855 153.637C114.914 169.97 104.635 186.859 86.3407 195.406C78.1337 199.24 66.1547 201.716 55.8236 201.716C54.8265 201.716 0.661663 201.862 0.114263 201.863ZM206.196 108.514C203.394 111.249 201.873 114.822 201.873 119.014C201.873 128.337 209.039 135.103 218.913 135.103C227.151 135.103 235.954 131.468 235.954 121.269C235.954 115.412 233.361 110.059 206.196 108.514ZM205.599 61.9351C205.288 66.336 205.107 72.2305 205.689 74.2339C207.068 79.0064 216.759 86.1065 231.879 91.6787C232 91.2788 232.065 90.8545 232.065 90.4062V78.5952C232.065 77.3667 232.979 76.3301 234.198 76.1767C253.642 73.7246 266.207 59.8373 266.207 40.7968C266.207 23.496 253.673 4.8759 226.154 4.8759C209.217 4.8759 193.115 12.7753 182.659 26.1088L198.041 40.5492C203.933 33.7372 214.05 28.9974 223.192 28.9974C233.282 28.9974 239.55 34.5931 239.55 43.6004C239.55 58.4339 227.142 61.7324 205.599 61.9351Z"
|
||||||
|
fill="#0A0A0A"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
|
|
@ -7,6 +7,7 @@ server/vbv_lernwelt/notify/email/email_services.py
|
||||||
server/vbv_lernwelt/static/
|
server/vbv_lernwelt/static/
|
||||||
server/vbv_lernwelt/media/
|
server/vbv_lernwelt/media/
|
||||||
server/vbv_lernwelt/edoniq_test/certificates/test.key
|
server/vbv_lernwelt/edoniq_test/certificates/test.key
|
||||||
|
server/vbv_lernwelt/shop/tests/test_datatrans_signature.py
|
||||||
server/vbv_lernwelt/shop/tests/test_create_signature.py
|
server/vbv_lernwelt/shop/tests/test_create_signature.py
|
||||||
supabase.md
|
supabase.md
|
||||||
scripts/supabase/init.sql
|
scripts/supabase/init.sql
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue