Merged in feature/VBV-554-umsetzung-login-vv (pull request #246)
Feature/VBV-554 umsetzung login vv Approved-by: Christian Cueni
This commit is contained in:
commit
04e26b5f77
|
|
@ -91,10 +91,10 @@ def main(app_name, image_name, environment_file):
|
|||
"AWS_S3_ACCESS_KEY_ID", "AKIAZJLREPUVWNBTJ5VY"
|
||||
),
|
||||
"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_STORAGE_BUCKET_NAME": env.str(
|
||||
"AWS_STORAGE_BUCKET_NAME", "myvbv-dev.iterativ.ch"
|
||||
),
|
||||
"AWS_S3_REGION_NAME": "eu-central-1",
|
||||
"AWS_STORAGE_BUCKET_NAME": "myvbv-dev.iterativ.ch",
|
||||
"DATATRANS_HMAC_KEY": env.str("DATATRANS_HMAC_KEY", ""),
|
||||
"DATATRANS_BASIC_AUTH_KEY": env.str("DATATRANS_BASIC_AUTH_KEY", ""),
|
||||
"FILE_UPLOAD_STORAGE": "s3",
|
||||
"IT_DJANGO_DEBUG": "false",
|
||||
"IT_SERVE_VUE": "false",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ APP_NAME=${1:-$(generate_default_app_name)}
|
|||
export VITE_APP_ENVIRONMENT="dev-$APP_NAME"
|
||||
|
||||
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"
|
||||
elif [[ "$APP_NAME" == prod* ]]; then
|
||||
export VITE_OAUTH_API_BASE_URL="https://edumgr.b2clogin.com/edumgr.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<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">
|
||||
<Transition mode="out-in" name="app">
|
||||
<component :is="Component" :key="componentKey"></component>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<AppFooter class="flex-none" />
|
||||
<AppFooter v-if="!route.meta.hideChrome" class="flex-none" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -19,6 +19,9 @@ import { graphqlClient } from "@/graphql/client";
|
|||
import eventBus from "@/utils/eventBus";
|
||||
import { provideClient } from "@urql/vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
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">
|
||||
import log from "loglevel";
|
||||
|
||||
import { getLoginURL } from "@/router/utils";
|
||||
import AccountMenu from "@/components/header/AccountMenu.vue";
|
||||
import MobileMenu from "@/components/header/MobileMenu.vue";
|
||||
import NotificationPopover from "@/components/notifications/NotificationPopover.vue";
|
||||
|
|
@ -274,7 +274,11 @@ onMounted(() => {
|
|||
</PopoverPanel>
|
||||
</Popover>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<svg
|
||||
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"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
circleFlatLearningContents,
|
||||
circleFlatLearningUnits,
|
||||
} from "@/services/circle";
|
||||
import { presignUpload, uploadFile } from "@/services/files";
|
||||
import { useCompletionStore } from "@/stores/completion";
|
||||
import { useCourseSessionsStore } from "@/stores/courseSessions";
|
||||
import { useDashboardStore } from "@/stores/dashboard";
|
||||
|
|
@ -435,3 +436,30 @@ export function useCourseStatistics() {
|
|||
|
||||
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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ export const itDelete = (url: RequestInfo) => {
|
|||
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>>();
|
||||
|
||||
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">
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import type { LoginMethod } from "@/types";
|
||||
import * as log from "loglevel";
|
||||
import { reactive } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
defineProps<{
|
||||
loginMethod: LoginMethod;
|
||||
}>();
|
||||
|
||||
log.debug("LoginView.vue created");
|
||||
log.debug(route.query);
|
||||
|
||||
|
|
@ -43,7 +38,6 @@ const userStore = useUserStore();
|
|||
<h1 class="mb-8">{{ $t("login.login") }}</h1>
|
||||
|
||||
<form
|
||||
v-if="loginMethod === 'local'"
|
||||
class="bg-white p-4 lg:p-8"
|
||||
@submit.prevent="
|
||||
userStore.handleLogin(
|
||||
|
|
@ -85,22 +79,6 @@ const userStore = useUserStore();
|
|||
/>
|
||||
</div>
|
||||
</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 class="container-medium">
|
||||
<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 LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
|
||||
import CourseDetailDates from "@/components/dashboard/CourseDetailDates.vue";
|
||||
import NoCourseSession from "@/components/dashboard/NoCourseSession.vue";
|
||||
|
||||
const dashboardStore = useDashboardStore();
|
||||
|
||||
|
|
@ -58,4 +59,5 @@ onMounted(dashboardStore.loadDashboardDetails);
|
|||
></component>
|
||||
</aside>
|
||||
</div>
|
||||
<NoCourseSession v-else class="container-medium mt-14" />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { presignUpload, uploadFile } from "@/services/files";
|
||||
import type { UserDataFileInfo } from "@/types";
|
||||
import { useFileUpload } from "@/composables";
|
||||
|
||||
const props = defineProps<{
|
||||
fileInfo: UserDataFileInfo | null;
|
||||
|
|
@ -10,6 +10,12 @@ const props = defineProps<{
|
|||
const emit = defineEmits(["fileUploaded", "fileDeleted"]);
|
||||
|
||||
const selectedFile = ref();
|
||||
const {
|
||||
upload: uploadFile,
|
||||
loading: uploadLoading,
|
||||
error: uploadError,
|
||||
fileInfo: uploadInfo,
|
||||
} = useFileUpload();
|
||||
|
||||
watch(
|
||||
() => props.fileInfo,
|
||||
|
|
@ -19,28 +25,11 @@ watch(
|
|||
{ immediate: true }
|
||||
);
|
||||
|
||||
const loading = ref(false);
|
||||
const uploadError = ref(false);
|
||||
|
||||
async function fileSelected(e: Event) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
watch(uploadInfo, (info) => {
|
||||
console.log("fileInfo changed", info);
|
||||
selectedFile.value = info;
|
||||
emit("fileUploaded", info.id);
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
selectedFile.value = null;
|
||||
|
|
@ -52,7 +41,7 @@ function handleDelete() {
|
|||
<div>
|
||||
<h4 class="mb-2 text-xl">{{ $t("a.Datei hochladen") }}</h4>
|
||||
|
||||
<template v-if="loading">
|
||||
<template v-if="uploadLoading">
|
||||
{{ $t("a.Laden...") }}
|
||||
</template>
|
||||
|
||||
|
|
@ -68,7 +57,8 @@ function handleDelete() {
|
|||
type="file"
|
||||
class="absolute opacity-0"
|
||||
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" />
|
||||
{{ $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 { useCourseSessionsStore } from "@/stores/courseSessions";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import type { NavigationGuard, RouteLocationNormalized } from "vue-router";
|
||||
|
|
@ -12,14 +13,26 @@ export const updateLoggedIn: NavigationGuard = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
export const redirectToLoginIfRequired: NavigationGuard = (to) => {
|
||||
const userStore = useUserStore();
|
||||
if (loginRequired(to) && !userStore.loggedIn) {
|
||||
const appEnv = import.meta.env.VITE_APP_ENVIRONMENT || "local";
|
||||
const ssoLogin = appEnv.startsWith("prod") || appEnv.startsWith("stage");
|
||||
return ssoLogin
|
||||
? `/login?next=${encodeURIComponent(to.fullPath)}`
|
||||
: `/login-local?next=${encodeURIComponent(to.fullPath)}`;
|
||||
export const redirectToLoginIfRequired: NavigationGuard = (to, from, next) => {
|
||||
const user = useUserStore();
|
||||
|
||||
// redirect guests to /start if they access /
|
||||
if (!user.loggedIn && to.path === "/") {
|
||||
return next("/start");
|
||||
}
|
||||
|
||||
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 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 {
|
||||
handleCourseSessionAsQueryParam,
|
||||
handleCurrentCourseSession,
|
||||
|
|
@ -7,6 +10,7 @@ import {
|
|||
updateLoggedIn,
|
||||
} from "@/router/guards";
|
||||
import { addToHistory } from "@/router/history";
|
||||
import { onboardingRedirect } from "@/router/onboarding";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
const router = createRouter({
|
||||
|
|
@ -20,20 +24,33 @@ const router = createRouter({
|
|||
},
|
||||
routes: [
|
||||
{
|
||||
path: "/login",
|
||||
component: LoginPage,
|
||||
props: { loginMethod: "sso" },
|
||||
path: "/start",
|
||||
name: "start",
|
||||
component: GuestStartPage,
|
||||
meta: {
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/start/vv",
|
||||
component: VVStartPage,
|
||||
name: "vvStart",
|
||||
meta: {
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/start/uk",
|
||||
component: UKStartPage,
|
||||
name: "ukStart",
|
||||
meta: {
|
||||
// no login required -> so `public === true`
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/login-local",
|
||||
component: LoginPage,
|
||||
props: { loginMethod: "local" },
|
||||
meta: {
|
||||
// no login required -> so `public === true`
|
||||
public: true,
|
||||
},
|
||||
},
|
||||
|
|
@ -229,6 +246,50 @@ const router = createRouter({
|
|||
path: "/course/:courseSlug/appointments",
|
||||
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",
|
||||
component: () => import("../pages/StyleGuidePage.vue"),
|
||||
|
|
@ -236,6 +297,7 @@ const router = createRouter({
|
|||
public: true,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
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}/`);
|
||||
}
|
||||
|
||||
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/`, {
|
||||
file_type: file.type,
|
||||
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 };
|
||||
}
|
||||
|
|
@ -48,7 +48,7 @@ export const useDashboardStore = defineStore("dashboard", () => {
|
|||
const loadDashboardConfig = async () => {
|
||||
if (dashboardConfigs.value.length > 0) return;
|
||||
const configData = await fetchDashboardConfig();
|
||||
if (configData) {
|
||||
if (configData && configData.length > 0) {
|
||||
dashboardConfigs.value = configData;
|
||||
await switchAndLoadDashboardConfig(configData[0]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export type UserState = {
|
|||
email: string;
|
||||
username: string;
|
||||
avatar_url: string;
|
||||
organisation: number | null;
|
||||
is_superuser: boolean;
|
||||
course_session_experts: string[];
|
||||
loggedIn: boolean;
|
||||
|
|
@ -57,6 +58,7 @@ const initialUserState: UserState = {
|
|||
username: "",
|
||||
avatar_url: "",
|
||||
is_superuser: false,
|
||||
organisation: 0,
|
||||
course_session_experts: [],
|
||||
loggedIn: false,
|
||||
language: defaultLanguage,
|
||||
|
|
@ -84,6 +86,19 @@ export const useUserStore = defineStore({
|
|||
getFullName(): string {
|
||||
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: {
|
||||
handleLogin(username: string, password: string, next = "/") {
|
||||
|
|
@ -131,5 +146,9 @@ export const useUserStore = defineStore({
|
|||
this.$state.language = language;
|
||||
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 {
|
||||
@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 {
|
||||
|
|
|
|||
|
|
@ -11,36 +11,36 @@ describe("login.cy.js", () => {
|
|||
});
|
||||
|
||||
it("can login to app with username/password", () => {
|
||||
cy.visit("/");
|
||||
cy.visit("/login-local");
|
||||
|
||||
cy.get("#username").type("test-student1@example.com");
|
||||
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.get('[data-cy="dashboard-title"]').should("contain", "Dashboard");
|
||||
cy.get("[data-cy=\"dashboard-title\"]").should("contain", "Dashboard");
|
||||
});
|
||||
|
||||
it("can login with helper function", () => {
|
||||
login("test-student1@example.com", "test");
|
||||
cy.visit("/");
|
||||
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.get("h1").should("contain", "Login");
|
||||
|
||||
cy.get("#username").type("test-student1@example.com");
|
||||
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",
|
||||
"Test Lehrgang",
|
||||
"Test Lehrgang"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ LOCAL_APPS = [
|
|||
"vbv_lernwelt.importer",
|
||||
"vbv_lernwelt.edoniq_test",
|
||||
"vbv_lernwelt.course_session_group",
|
||||
"vbv_lernwelt.shop",
|
||||
]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
|
@ -557,7 +558,12 @@ else:
|
|||
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
||||
|
||||
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
|
||||
|
|
@ -583,38 +589,45 @@ if "django_redis.cache.RedisCache" in env("IT_DJANGO_CACHE_BACKEND", default="")
|
|||
},
|
||||
}
|
||||
|
||||
# OAuth/OpenId Connect
|
||||
IT_OAUTH_TENANT_ID = env.str("IT_OAUTH_TENANT_ID", default=None)
|
||||
# OAuth (SSO) settings
|
||||
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:
|
||||
IT_OAUTH_AUTHORIZE_PARAMS = {"tenant_id": IT_OAUTH_TENANT_ID}
|
||||
else:
|
||||
IT_OAUTH_AUTHORIZE_PARAMS = {}
|
||||
|
||||
OAUTH = {
|
||||
"client_name": env("IT_OAUTH_CLIENT_NAME", default="lernetz"),
|
||||
"client_id": env("IT_OAUTH_CLIENT_ID", default="iterativ"),
|
||||
"client_secret": env("IT_OAUTH_CLIENT_SECRET", default=""),
|
||||
"authorize_params": IT_OAUTH_AUTHORIZE_PARAMS,
|
||||
"access_token_params": IT_OAUTH_AUTHORIZE_PARAMS,
|
||||
"api_base_url": env(
|
||||
"IT_OAUTH_API_BASE_URL",
|
||||
default="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/",
|
||||
),
|
||||
"local_redirect_uri": env(
|
||||
"IT_OAUTH_LOCAL_REDIRECT_URI", default="http://localhost:8000/sso/callback/"
|
||||
),
|
||||
"server_metadata_url": env(
|
||||
"IT_OAUTH_SERVER_METADATA_URL",
|
||||
default="https://sso.test.b.lernetz.host/auth/realms/vbv/.well-known/openid-configuration",
|
||||
),
|
||||
"client_kwargs": {
|
||||
"scope": env("IT_OAUTH_SCOPE", default="openid email"),
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"token_placement": "body",
|
||||
AUTHLIB_OAUTH_CLIENTS = {
|
||||
"signup": {
|
||||
# azure
|
||||
"client_id": env("OAUTH_SIGNUP_CLIENT_ID", ""),
|
||||
"client_secret": env("OAUTH_SIGNUP_CLIENT_SECRET", ""),
|
||||
"server_metadata_url": env("OAUTH_SIGNUP_SERVER_METADATA_URL", ""),
|
||||
"access_token_params": OAUTH_SIGNUP_PARAMS,
|
||||
"authorize_params": OAUTH_SIGNUP_PARAMS,
|
||||
"client_kwargs": {
|
||||
"scope": "openid",
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"token_placement": "body",
|
||||
},
|
||||
},
|
||||
"signin": {
|
||||
# keycloak
|
||||
"client_id": env("OAUTH_SIGNIN_CLIENT_ID", ""),
|
||||
"client_secret": env("OAUTH_SIGNIN_CLIENT_SECRET", ""),
|
||||
"server_metadata_url": env("OAUTH_SIGNIN_SERVER_METADATA_URL", ""),
|
||||
"client_kwargs": {
|
||||
"scope": "openid email profile",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
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 = {
|
||||
"SCHEMA": "vbv_lernwelt.core.schema.schema",
|
||||
"SCHEMA_OUTPUT": "../client/src/gql/schema.graphql",
|
||||
|
|
@ -647,6 +660,26 @@ NOTIFICATIONS_NOTIFICATION_MODEL = "notify.Notification"
|
|||
# sendgrid (email notifications)
|
||||
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
|
||||
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 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.assignment.views import request_assignment_completion_status
|
||||
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
|
||||
from vbv_lernwelt.core.schema import schema
|
||||
|
|
@ -18,7 +20,6 @@ from vbv_lernwelt.core.views import (
|
|||
check_rate_limit,
|
||||
cypress_reset_view,
|
||||
generate_web_component_icons,
|
||||
me_user_view,
|
||||
permission_denied_view,
|
||||
rate_limit_exceeded_view,
|
||||
vue_home,
|
||||
|
|
@ -98,6 +99,8 @@ urlpatterns = [
|
|||
# user management
|
||||
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/entities/$', list_entities, name='list_entities'),
|
||||
|
||||
re_path(r'api/core/login/$', django_view_authentication_exempt(vue_login),
|
||||
name='vue_login'),
|
||||
re_path(r'api/core/logout/$', vue_logout, name='vue_logout'),
|
||||
|
|
@ -168,6 +171,9 @@ urlpatterns = [
|
|||
path(r'api/core/edoniq-test/export-users-trainers/', export_students_and_trainers,
|
||||
name='edoniq_export_students_and_trainers'),
|
||||
|
||||
# shop
|
||||
path("api/shop/", include("vbv_lernwelt.shop.urls")),
|
||||
|
||||
# importer
|
||||
path(
|
||||
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)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
from rest_framework.decorators import api_view
|
||||
from rest_framework.response import Response
|
||||
|
||||
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)
|
||||
|
|
@ -2,7 +2,7 @@ from django.contrib import admin
|
|||
from django.contrib.auth import admin as auth_admin, get_user_model
|
||||
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
|
||||
|
||||
User = get_user_model()
|
||||
|
|
@ -43,6 +43,7 @@ class UserAdmin(auth_admin.UserAdmin):
|
|||
},
|
||||
),
|
||||
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
|
||||
(_("Profile"), {"fields": ("organisation", "language")}),
|
||||
(_("Additional data"), {"fields": ("additional_json_data",)}),
|
||||
)
|
||||
list_display = [
|
||||
|
|
@ -78,3 +79,13 @@ class JobLogAdmin(LogAdmin):
|
|||
if obj.ended:
|
||||
return (obj.ended - obj.started).seconds // 60
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""
|
||||
Default custom user model for VBV Lernwelt.
|
||||
|
|
@ -29,6 +44,10 @@ class User(AbstractUser):
|
|||
additional_json_data = JSONField(default=dict, blank=True)
|
||||
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):
|
||||
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.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_session_group.models import CourseSessionGroup
|
||||
|
||||
|
|
@ -25,6 +25,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
"email",
|
||||
"username",
|
||||
"avatar_url",
|
||||
"organisation",
|
||||
"is_superuser",
|
||||
"course_session_experts",
|
||||
"language",
|
||||
|
|
@ -52,3 +53,9 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
)
|
||||
|
||||
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.contrib.auth import authenticate, login, logout
|
||||
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.template import loader
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
|
|
@ -31,12 +36,21 @@ logger = structlog.get_logger(__name__)
|
|||
@ensure_csrf_cookie
|
||||
def vue_home(request, *args):
|
||||
if settings.IT_SERVE_VUE:
|
||||
from gunicorn.util import is_hoppish
|
||||
|
||||
try:
|
||||
res = requests.get(f"{settings.IT_SERVE_VUE_URL}{request.get_full_path()}")
|
||||
content = res.text
|
||||
headers = res.headers
|
||||
content_type = headers.get("content-type", "text/html")
|
||||
return HttpResponse(content, content_type=content_type)
|
||||
path = request.get_full_path()
|
||||
res = requests.get(f"{settings.IT_SERVE_VUE_URL}{path}", stream=True)
|
||||
response = StreamingHttpResponse(
|
||||
streaming_content=(chunk for chunk in res.iter_content(4096)),
|
||||
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:
|
||||
return HttpResponse(
|
||||
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"])
|
||||
def vue_logout(request):
|
||||
logout(request)
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ from vbv_lernwelt.learnpath.tests.learning_path_factories import (
|
|||
)
|
||||
|
||||
|
||||
def create_course(title: str) -> Tuple[Course, CoursePage]:
|
||||
course = Course.objects.create(title=title, category_name="Handlungsfeld")
|
||||
def create_course(title: str, _id=None) -> Tuple[Course, CoursePage]:
|
||||
course = Course.objects.create(id=_id, title=title, category_name="Handlungsfeld")
|
||||
|
||||
course_page = CoursePageFactory(
|
||||
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,
|
||||
contract_number: str = "",
|
||||
date_of_birth: str = "",
|
||||
):
|
||||
) -> User:
|
||||
logger.debug(
|
||||
"create_or_update_user",
|
||||
email=email,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,13 @@ class EmailTemplate(Enum):
|
|||
# VBV - Neues Feedback für Circle
|
||||
NEW_FEEDBACK = {"de": "d-40fb94d5149949e7b8e7ddfcf0fcfdde"}
|
||||
|
||||
# Versicherungsvermittler (after buying a course)
|
||||
WELCOME_MAIL_VV = {
|
||||
"de": "d-308a72c779b74c8487cdec03c772ad13",
|
||||
"fr": "d-1a0958c7798c4dd18f730491e920eab5",
|
||||
"it": "d-0882ec9c92f64312b9f358481a943c9a",
|
||||
}
|
||||
|
||||
|
||||
def send_email(
|
||||
recipient_email: str,
|
||||
|
|
|
|||
|
|
@ -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 django.conf import settings
|
||||
|
||||
# # https://docs.authlib.org/en/latest/client/frameworks.html#frameworks-clients
|
||||
oauth = OAuth()
|
||||
oauth.register(
|
||||
name=settings.OAUTH["client_name"],
|
||||
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"],
|
||||
)
|
||||
oauth.register(name="signup")
|
||||
oauth.register(name="signin")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
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(
|
||||
r"callback/",
|
||||
django_view_authentication_exempt(views.authorize),
|
||||
django_view_authentication_exempt(views.authorize_signin),
|
||||
name="authorize",
|
||||
),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import base64
|
||||
import json
|
||||
|
||||
import structlog as structlog
|
||||
from authlib.integrations.base_client import OAuthError
|
||||
from django.conf import settings
|
||||
|
|
@ -5,53 +8,116 @@ from django.contrib.auth import login as dj_login
|
|||
from django.shortcuts import redirect
|
||||
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.sso.client import oauth
|
||||
from vbv_lernwelt.sso.jwt import decode_jwt
|
||||
|
||||
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):
|
||||
oauth_client = oauth.create_client(settings.OAUTH["client_name"])
|
||||
redirect_uri = settings.OAUTH["local_redirect_uri"]
|
||||
language = request.GET.get("lang", "de")
|
||||
return oauth_client.authorize_redirect(request, redirect_uri, lang=language)
|
||||
def signin(request):
|
||||
"""
|
||||
Called directly from the frontend AND as a redirect from signup!
|
||||
"""
|
||||
|
||||
|
||||
def authorize(request):
|
||||
try:
|
||||
logger.debug(request, label="sso")
|
||||
token = getattr(oauth, settings.OAUTH["client_name"]).authorize_access_token(
|
||||
request
|
||||
# redirect from signup
|
||||
if "state" in request.GET:
|
||||
state_decoded = json.loads(
|
||||
base64.urlsafe_b64decode(request.GET.get("state").encode()).decode()
|
||||
)
|
||||
decoded_token = decode_jwt(token["id_token"])
|
||||
# logger.debug(label="sso", decoded_token=decoded_token)
|
||||
state_course_param = state_decoded.get("course")
|
||||
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:
|
||||
logger.error(e, exc_info=True, label="sso")
|
||||
if not settings.DEBUG:
|
||||
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(
|
||||
email=user_data.get("email").lower(),
|
||||
sso_id=user_data.get("sso_id"),
|
||||
first_name=user_data.get("first_name", ""),
|
||||
last_name=user_data.get("last_name", ""),
|
||||
email=id_token.get("email", ""),
|
||||
sso_id=id_token.get("oid"),
|
||||
first_name=id_token.get("given_name", ""),
|
||||
last_name=id_token.get("family_name", ""),
|
||||
)
|
||||
|
||||
dj_login(request, user)
|
||||
return redirect(f"/")
|
||||
|
||||
|
||||
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"),
|
||||
}
|
||||
return get_redirect_uri(user=user, course=course, next_url=next_url)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue