Merge branch 'develop' into feature/VBV-597-umsetzung-cockpit-lernbegleitung

This commit is contained in:
Reto Aebersold 2023-12-19 10:01:16 +01:00
commit 480c82e466
100 changed files with 5558 additions and 312 deletions

View File

@ -91,10 +91,10 @@ def main(app_name, image_name, environment_file):
"AWS_S3_ACCESS_KEY_ID", "AKIAZJLREPUVWNBTJ5VY" "AWS_S3_ACCESS_KEY_ID", "AKIAZJLREPUVWNBTJ5VY"
), ),
"AWS_S3_SECRET_ACCESS_KEY": env.str("AWS_S3_SECRET_ACCESS_KEY", ""), "AWS_S3_SECRET_ACCESS_KEY": env.str("AWS_S3_SECRET_ACCESS_KEY", ""),
"AWS_S3_REGION_NAME": env.str("AWS_S3_REGION_NAME", "eu-central-1"), "AWS_S3_REGION_NAME": "eu-central-1",
"AWS_STORAGE_BUCKET_NAME": env.str( "AWS_STORAGE_BUCKET_NAME": "myvbv-dev.iterativ.ch",
"AWS_STORAGE_BUCKET_NAME", "myvbv-dev.iterativ.ch" "DATATRANS_HMAC_KEY": env.str("DATATRANS_HMAC_KEY", ""),
), "DATATRANS_BASIC_AUTH_KEY": env.str("DATATRANS_BASIC_AUTH_KEY", ""),
"FILE_UPLOAD_STORAGE": "s3", "FILE_UPLOAD_STORAGE": "s3",
"IT_DJANGO_DEBUG": "false", "IT_DJANGO_DEBUG": "false",
"IT_SERVE_VUE": "false", "IT_SERVE_VUE": "false",

View File

@ -29,7 +29,7 @@ APP_NAME=${1:-$(generate_default_app_name)}
export VITE_APP_ENVIRONMENT="dev-$APP_NAME" export VITE_APP_ENVIRONMENT="dev-$APP_NAME"
if [[ "$APP_NAME" == "myvbv-stage" ]]; then if [[ "$APP_NAME" == "myvbv-stage" ]]; then
export VITE_OAUTH_API_BASE_URL="https://vbvtst.b2clogin.com/vbvtst.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/" export VITE_OAUTH_API_BASE_URL="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/"
export VITE_APP_ENVIRONMENT="stage-caprover" export VITE_APP_ENVIRONMENT="stage-caprover"
elif [[ "$APP_NAME" == prod* ]]; then elif [[ "$APP_NAME" == prod* ]]; then
export VITE_OAUTH_API_BASE_URL="https://edumgr.b2clogin.com/edumgr.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/" export VITE_OAUTH_API_BASE_URL="https://edumgr.b2clogin.com/edumgr.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/"

View File

@ -1,12 +1,12 @@
<template> <template>
<div class="flex min-h-full flex-col"> <div class="flex min-h-full flex-col">
<MainNavigationBar class="flex-none" /> <MainNavigationBar v-if="!route.meta.hideChrome" class="flex-none" />
<RouterView v-slot="{ Component }" class="flex-auto"> <RouterView v-slot="{ Component }" class="flex-auto">
<Transition mode="out-in" name="app"> <Transition mode="out-in" name="app">
<component :is="Component" :key="componentKey"></component> <component :is="Component" :key="componentKey"></component>
</Transition> </Transition>
</RouterView> </RouterView>
<AppFooter class="flex-none" /> <AppFooter v-if="!route.meta.hideChrome" class="flex-none" />
</div> </div>
</template> </template>
@ -19,6 +19,9 @@ import { graphqlClient } from "@/graphql/client";
import eventBus from "@/utils/eventBus"; import eventBus from "@/utils/eventBus";
import { provideClient } from "@urql/vue"; import { provideClient } from "@urql/vue";
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const componentKey = ref(1); const componentKey = ref(1);

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

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

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import log from "loglevel"; import log from "loglevel";
import { getLoginURL } from "@/router/utils";
import AccountMenu from "@/components/header/AccountMenu.vue"; import AccountMenu from "@/components/header/AccountMenu.vue";
import MobileMenu from "@/components/header/MobileMenu.vue"; import MobileMenu from "@/components/header/MobileMenu.vue";
import NotificationPopover from "@/components/notifications/NotificationPopover.vue"; import NotificationPopover from "@/components/notifications/NotificationPopover.vue";
@ -333,7 +333,11 @@ const hasMentorManagementMenu = computed(() => {
</PopoverPanel> </PopoverPanel>
</Popover> </Popover>
</div> </div>
<div v-else><a class="" href="/login">Login</a></div> <div v-else>
<a class="" :href="getLoginURL({ lang: userStore.language })">
Login
</a>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@
<div role="status"> <div role="status">
<svg <svg
aria-hidden="true" aria-hidden="true"
class="mr-2 h-8 w-8 animate-spin fill-blue-900 text-gray-200 dark:text-gray-600" class="h-8 w-8 animate-spin fill-blue-900 text-gray-200 dark:text-gray-600"
viewBox="0 0 100 101" viewBox="0 0 100 101"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View File

@ -6,6 +6,7 @@ import {
circleFlatLearningContents, circleFlatLearningContents,
circleFlatLearningUnits, circleFlatLearningUnits,
} from "@/services/circle"; } from "@/services/circle";
import { presignUpload, uploadFile } from "@/services/files";
import { useCompletionStore } from "@/stores/completion"; import { useCompletionStore } from "@/stores/completion";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useDashboardStore } from "@/stores/dashboard"; import { useDashboardStore } from "@/stores/dashboard";
@ -435,3 +436,30 @@ export function useCourseStatistics() {
return { courseSessionName, circleMeta }; return { courseSessionName, circleMeta };
} }
export function useFileUpload() {
const error = ref(false);
const loading = ref(false);
const fileInfo = ref({} as { id: string; name: string; url: string });
async function upload(e: Event) {
const { files } = e.target as HTMLInputElement;
if (!files?.length) return;
try {
error.value = false;
loading.value = true;
const file = files[0];
const presignData = await presignUpload(file);
await uploadFile(presignData.pre_sign, file);
fileInfo.value = presignData.file_info;
} catch (e) {
console.error(e);
error.value = true;
} finally {
loading.value = false;
}
}
return { upload, error, loading, fileInfo };
}

View File

@ -67,6 +67,10 @@ export const itDelete = (url: RequestInfo) => {
return itPost(url, {}, { method: "DELETE" }); return itPost(url, {}, { method: "DELETE" });
}; };
export const itPut = (url: RequestInfo, data: unknown) => {
return itPost(url, data, { method: "PUT" });
};
const itGetPromiseCache = new Map<string, Promise<any>>(); const itGetPromiseCache = new Map<string, Promise<any>>();
export function bustItGetCache(key?: string) { export function bustItGetCache(key?: string) {

View File

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

View File

@ -1,16 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import type { LoginMethod } from "@/types";
import * as log from "loglevel"; import * as log from "loglevel";
import { reactive } from "vue"; import { reactive } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
const route = useRoute(); const route = useRoute();
defineProps<{
loginMethod: LoginMethod;
}>();
log.debug("LoginView.vue created"); log.debug("LoginView.vue created");
log.debug(route.query); log.debug(route.query);
@ -43,7 +38,6 @@ const userStore = useUserStore();
<h1 class="mb-8">{{ $t("login.login") }}</h1> <h1 class="mb-8">{{ $t("login.login") }}</h1>
<form <form
v-if="loginMethod === 'local'"
class="bg-white p-4 lg:p-8" class="bg-white p-4 lg:p-8"
@submit.prevent=" @submit.prevent="
userStore.handleLogin( userStore.handleLogin(
@ -85,22 +79,6 @@ const userStore = useUserStore();
/> />
</div> </div>
</form> </form>
<div v-if="loginMethod === 'sso'" class="bg-white p-4 lg:p-8">
<p>
{{ $t("login.ssoText") }}
</p>
<p class="btn-primary mt-8">
<a :href="`/sso/login/?lang=${userStore.language}`">
{{ $t("login.ssoLogin") }}
</a>
</p>
<p class="mt-8">
<a href="/login-local">
{{ $t("login.demoLogin") }}
</a>
</p>
</div>
</div> </div>
<div class="container-medium"> <div class="container-medium">
<h2 class="mb-8">{{ $t("footer.contact") }}</h2> <h2 class="mb-8">{{ $t("footer.contact") }}</h2>

View File

@ -10,6 +10,7 @@ import type { DashboardType } from "@/gql/graphql";
import SimpleCoursePage from "@/pages/dashboard/SimpleCoursePage.vue"; import SimpleCoursePage from "@/pages/dashboard/SimpleCoursePage.vue";
import LoadingSpinner from "@/components/ui/LoadingSpinner.vue"; import LoadingSpinner from "@/components/ui/LoadingSpinner.vue";
import CourseDetailDates from "@/components/dashboard/CourseDetailDates.vue"; import CourseDetailDates from "@/components/dashboard/CourseDetailDates.vue";
import NoCourseSession from "@/components/dashboard/NoCourseSession.vue";
const dashboardStore = useDashboardStore(); const dashboardStore = useDashboardStore();
@ -58,4 +59,5 @@ onMounted(dashboardStore.loadDashboardDetails);
></component> ></component>
</aside> </aside>
</div> </div>
<NoCourseSession v-else class="container-medium mt-14" />
</template> </template>

View File

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import { presignUpload, uploadFile } from "@/services/files";
import type { UserDataFileInfo } from "@/types"; import type { UserDataFileInfo } from "@/types";
import { useFileUpload } from "@/composables";
const props = defineProps<{ const props = defineProps<{
fileInfo: UserDataFileInfo | null; fileInfo: UserDataFileInfo | null;
@ -10,6 +10,12 @@ const props = defineProps<{
const emit = defineEmits(["fileUploaded", "fileDeleted"]); const emit = defineEmits(["fileUploaded", "fileDeleted"]);
const selectedFile = ref(); const selectedFile = ref();
const {
upload: uploadFile,
loading: uploadLoading,
error: uploadError,
fileInfo: uploadInfo,
} = useFileUpload();
watch( watch(
() => props.fileInfo, () => props.fileInfo,
@ -19,28 +25,11 @@ watch(
{ immediate: true } { immediate: true }
); );
const loading = ref(false); watch(uploadInfo, (info) => {
const uploadError = ref(false); console.log("fileInfo changed", info);
selectedFile.value = info;
async function fileSelected(e: Event) { emit("fileUploaded", info.id);
const { files } = e.target as HTMLInputElement; });
if (!files?.length) return;
try {
uploadError.value = false;
loading.value = true;
const file = files[0];
const presignData = await presignUpload(file);
await uploadFile(presignData.pre_sign, file);
selectedFile.value = presignData.file_info;
emit("fileUploaded", presignData.file_info.id);
} catch (error) {
console.error(error);
uploadError.value = true;
} finally {
loading.value = false;
}
}
function handleDelete() { function handleDelete() {
selectedFile.value = null; selectedFile.value = null;
@ -52,7 +41,7 @@ function handleDelete() {
<div> <div>
<h4 class="mb-2 text-xl">{{ $t("a.Datei hochladen") }}</h4> <h4 class="mb-2 text-xl">{{ $t("a.Datei hochladen") }}</h4>
<template v-if="loading"> <template v-if="uploadLoading">
{{ $t("a.Laden...") }} {{ $t("a.Laden...") }}
</template> </template>
@ -68,7 +57,8 @@ function handleDelete() {
type="file" type="file"
class="absolute opacity-0" class="absolute opacity-0"
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.mov,.ppt,.pptx,.mp4" accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.mov,.ppt,.pptx,.mp4"
@change="fileSelected" :disabled="uploadLoading"
@change="uploadFile"
/> />
<it-icon-document class="mr-1.5 h-7 w-7 text-blue-800" /> <it-icon-document class="mr-1.5 h-7 w-7 text-blue-800" />
{{ $t("a.Datei auswählen") }} {{ $t("a.Datei auswählen") }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { getLoginURLNext, shouldUseSSO } from "@/router/utils";
import { useCockpitStore } from "@/stores/cockpit"; import { useCockpitStore } from "@/stores/cockpit";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
@ -13,14 +14,26 @@ export const updateLoggedIn: NavigationGuard = async () => {
} }
}; };
export const redirectToLoginIfRequired: NavigationGuard = (to) => { export const redirectToLoginIfRequired: NavigationGuard = (to, from, next) => {
const userStore = useUserStore(); const user = useUserStore();
if (loginRequired(to) && !userStore.loggedIn) {
const appEnv = import.meta.env.VITE_APP_ENVIRONMENT || "local"; // redirect guests to /start if they access /
const ssoLogin = appEnv.startsWith("prod") || appEnv.startsWith("stage"); if (!user.loggedIn && to.path === "/") {
return ssoLogin return next("/start");
? `/login?next=${encodeURIComponent(to.fullPath)}` }
: `/login-local?next=${encodeURIComponent(to.fullPath)}`;
if (loginRequired(to) && !user.loggedIn) {
const loginURL = getLoginURLNext();
if (shouldUseSSO()) {
// Redirect to SSO login page, handled by the server
window.location.href = loginURL;
} else {
// Handle local login with Vue router
next(loginURL);
}
} else {
// If login is not required or user is already logged in, continue with the navigation
next();
} }
}; };

View File

@ -1,5 +1,8 @@
import DashboardPage from "@/pages/dashboard/DashboardPage.vue";
import LoginPage from "@/pages/LoginPage.vue"; import LoginPage from "@/pages/LoginPage.vue";
import DashboardPage from "@/pages/dashboard/DashboardPage.vue";
import GuestStartPage from "@/pages/start/GuestStartPage.vue";
import UKStartPage from "@/pages/start/UKStartPage.vue";
import VVStartPage from "@/pages/start/VVStartPage.vue";
import { import {
handleAcceptLearningMentorInvitation, handleAcceptLearningMentorInvitation,
handleCockpit, handleCockpit,
@ -9,6 +12,7 @@ import {
updateLoggedIn, updateLoggedIn,
} from "@/router/guards"; } from "@/router/guards";
import { addToHistory } from "@/router/history"; import { addToHistory } from "@/router/history";
import { onboardingRedirect } from "@/router/onboarding";
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
const router = createRouter({ const router = createRouter({
@ -22,20 +26,33 @@ const router = createRouter({
}, },
routes: [ routes: [
{ {
path: "/login", path: "/start",
component: LoginPage, name: "start",
props: { loginMethod: "sso" }, component: GuestStartPage,
meta: {
public: true,
},
},
{
path: "/start/vv",
component: VVStartPage,
name: "vvStart",
meta: {
public: true,
},
},
{
path: "/start/uk",
component: UKStartPage,
name: "ukStart",
meta: { meta: {
// no login required -> so `public === true`
public: true, public: true,
}, },
}, },
{ {
path: "/login-local", path: "/login-local",
component: LoginPage, component: LoginPage,
props: { loginMethod: "local" },
meta: { meta: {
// no login required -> so `public === true`
public: true, public: true,
}, },
}, },
@ -300,6 +317,50 @@ const router = createRouter({
path: "/course/:courseSlug/appointments", path: "/course/:courseSlug/appointments",
component: () => import("@/pages/AppointmentsPage.vue"), component: () => import("@/pages/AppointmentsPage.vue"),
}, },
{
path: "/onboarding/:courseType",
props: true,
component: () => import("@/pages/onboarding/WizardBase.vue"),
meta: {
public: true,
hideChrome: true,
},
beforeEnter: onboardingRedirect,
children: [
{
path: "account/create",
component: () => import("@/pages/onboarding/AccountSetup.vue"),
name: "accountCreate",
props: true,
},
{
path: "account/confirm",
component: () => import("@/pages/onboarding/AccountConfirm.vue"),
name: "accountConfirm",
},
{
path: "account/profile",
component: () => import("@/pages/onboarding/AccountProfile.vue"),
name: "accountProfile",
},
{
path: "account/complete",
component: () => import("@/pages/onboarding/uk/SetupComplete.vue"),
name: "setupComplete",
},
{
path: "checkout/address",
component: () => import("@/pages/onboarding/vv/CheckoutAddress.vue"),
name: "checkoutAddress",
props: true,
},
{
path: "checkout/complete",
component: () => import("@/pages/onboarding/vv/CheckoutComplete.vue"),
name: "checkoutComplete",
},
],
},
{ {
path: "/styleguide", path: "/styleguide",
component: () => import("../pages/StyleGuidePage.vue"), component: () => import("../pages/StyleGuidePage.vue"),
@ -307,6 +368,7 @@ const router = createRouter({
public: true, public: true,
}, },
}, },
{ {
path: "/:pathMatch(.*)*", path: "/:pathMatch(.*)*",
component: () => import("../pages/404Page.vue"), component: () => import("../pages/404Page.vue"),

View File

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

View File

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

View File

@ -107,7 +107,19 @@ export async function fetchCourseSessionDocuments(courseSessionId: string) {
return itGetCached(`/api/core/document/list/${courseSessionId}/`); return itGetCached(`/api/core/document/list/${courseSessionId}/`);
} }
export async function presignUpload(file: File) { type PresignResponse = {
pre_sign: {
url: string;
fields: Record<string, string>;
};
file_info: {
id: string;
name: string;
url: string;
};
};
export async function presignUpload(file: File): Promise<PresignResponse> {
return await itPost(`/api/core/storage/presign/`, { return await itPost(`/api/core/storage/presign/`, {
file_type: file.type, file_type: file.type,
file_name: file.name, file_name: file.name,

View File

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

View File

@ -25,6 +25,7 @@ export type UserState = {
email: string; email: string;
username: string; username: string;
avatar_url: string; avatar_url: string;
organisation: number | null;
is_superuser: boolean; is_superuser: boolean;
course_session_experts: string[]; course_session_experts: string[];
loggedIn: boolean; loggedIn: boolean;
@ -57,6 +58,7 @@ const initialUserState: UserState = {
username: "", username: "",
avatar_url: "", avatar_url: "",
is_superuser: false, is_superuser: false,
organisation: 0,
course_session_experts: [], course_session_experts: [],
loggedIn: false, loggedIn: false,
language: defaultLanguage, language: defaultLanguage,
@ -84,6 +86,19 @@ export const useUserStore = defineStore({
getFullName(): string { getFullName(): string {
return `${this.first_name} ${this.last_name}`.trim(); return `${this.first_name} ${this.last_name}`.trim();
}, },
languageName(): string {
if (this.language === "de") {
return "Deutsch";
}
if (this.language === "fr") {
return "Français";
}
if (this.language === "it") {
return "Italiano";
}
return this.language;
},
}, },
actions: { actions: {
handleLogin(username: string, password: string, next = "/") { handleLogin(username: string, password: string, next = "/") {
@ -131,5 +146,9 @@ export const useUserStore = defineStore({
this.$state.language = language; this.$state.language = language;
await itPost("/api/core/me/", { language }, { method: "PUT" }); await itPost("/api/core/me/", { language }, { method: "PUT" });
}, },
async setUserOrganisation(organisation: number) {
this.$state.organisation = organisation;
await itPost("/api/core/me/", { organisation }, { method: "PUT" });
},
}, },
}); });

View File

@ -158,6 +158,27 @@ textarea {
.btn-large-icon { .btn-large-icon {
@apply flex items-center px-6 py-3 text-xl font-bold; @apply flex items-center px-6 py-3 text-xl font-bold;
} }
.circle-numbered-list {
@apply my-8 list-outside list-decimal list-none pl-0;
counter-reset: list-counter;
}
.circle-numbered-list li {
@apply relative mb-8 pl-10;
counter-increment: list-counter;
}
.circle-numbered-list li:last-of-type {
@apply mb-0;
}
.circle-numbered-list li::before {
content: counter(list-counter);
@apply absolute left-0 flex h-6 w-6 items-center justify-center rounded-full border border-gray-500 text-sm;
top: 1rem;
transform: translateY(-50%);
}
} }
@layer utilities { @layer utilities {

View File

@ -11,36 +11,36 @@ describe("login.cy.js", () => {
}); });
it("can login to app with username/password", () => { it("can login to app with username/password", () => {
cy.visit("/"); cy.visit("/login-local");
cy.get("#username").type("test-student1@example.com"); cy.get("#username").type("test-student1@example.com");
cy.get("#password").type("test"); cy.get("#password").type("test");
cy.get('[data-cy="login-button"]').click(); cy.get("[data-cy=\"login-button\"]").click();
cy.request("/api/core/me").its("status").should("eq", 200); cy.request("/api/core/me").its("status").should("eq", 200);
cy.get('[data-cy="dashboard-title"]').should("contain", "Dashboard"); cy.get("[data-cy=\"dashboard-title\"]").should("contain", "Dashboard");
}); });
it("can login with helper function", () => { it("can login with helper function", () => {
login("test-student1@example.com", "test"); login("test-student1@example.com", "test");
cy.visit("/"); cy.visit("/");
cy.request("/api/core/me").its("status").should("eq", 200); cy.request("/api/core/me").its("status").should("eq", 200);
cy.get('[data-cy="dashboard-title"]').should("contain", "Dashboard"); cy.get("[data-cy=\"dashboard-title\"]").should("contain", "Dashboard");
}); });
it("login will redirect to requestet page", () => { it("login will redirect to requested page", () => {
cy.visit("/course/test-lehrgang/learn"); cy.visit("/course/test-lehrgang/learn");
cy.get("h1").should("contain", "Login"); cy.get("h1").should("contain", "Login");
cy.get("#username").type("test-student1@example.com"); cy.get("#username").type("test-student1@example.com");
cy.get("#password").type("test"); cy.get("#password").type("test");
cy.get('[data-cy="login-button"]').click(); cy.get("[data-cy=\"login-button\"]").click();
cy.get('[data-cy="learning-path-title"]').should( cy.get("[data-cy=\"learning-path-title\"]").should(
"contain", "contain",
"Test Lehrgang", "Test Lehrgang"
); );
}); });
}); });

View File

@ -131,6 +131,7 @@ LOCAL_APPS = [
"vbv_lernwelt.importer", "vbv_lernwelt.importer",
"vbv_lernwelt.edoniq_test", "vbv_lernwelt.edoniq_test",
"vbv_lernwelt.course_session_group", "vbv_lernwelt.course_session_group",
"vbv_lernwelt.shop",
"vbv_lernwelt.learning_mentor", "vbv_lernwelt.learning_mentor",
] ]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
@ -558,7 +559,12 @@ else:
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = env.list( ALLOWED_HOSTS = env.list(
"IT_DJANGO_ALLOWED_HOSTS", default=["localhost", "0.0.0.0", "127.0.0.1"] "IT_DJANGO_ALLOWED_HOSTS",
default=[
"localhost",
"0.0.0.0",
"127.0.0.1",
],
) )
# CACHES # CACHES
@ -584,38 +590,45 @@ if "django_redis.cache.RedisCache" in env("IT_DJANGO_CACHE_BACKEND", default="")
}, },
} }
# OAuth/OpenId Connect # OAuth (SSO) settings
IT_OAUTH_TENANT_ID = env.str("IT_OAUTH_TENANT_ID", default=None) OAUTH_SIGNUP_TENANT_ID = env("OAUTH_SIGNUP_TENANT_ID", default=None)
OAUTH_SIGNUP_PARAMS = (
{"tenant_id": OAUTH_SIGNUP_TENANT_ID} if OAUTH_SIGNUP_TENANT_ID else {}
)
if IT_OAUTH_TENANT_ID: AUTHLIB_OAUTH_CLIENTS = {
IT_OAUTH_AUTHORIZE_PARAMS = {"tenant_id": IT_OAUTH_TENANT_ID} "signup": {
else: # azure
IT_OAUTH_AUTHORIZE_PARAMS = {} "client_id": env("OAUTH_SIGNUP_CLIENT_ID", ""),
"client_secret": env("OAUTH_SIGNUP_CLIENT_SECRET", ""),
OAUTH = { "server_metadata_url": env("OAUTH_SIGNUP_SERVER_METADATA_URL", ""),
"client_name": env("IT_OAUTH_CLIENT_NAME", default="lernetz"), "access_token_params": OAUTH_SIGNUP_PARAMS,
"client_id": env("IT_OAUTH_CLIENT_ID", default="iterativ"), "authorize_params": OAUTH_SIGNUP_PARAMS,
"client_secret": env("IT_OAUTH_CLIENT_SECRET", default=""),
"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": { "client_kwargs": {
"scope": env("IT_OAUTH_SCOPE", default="openid email"), "scope": "openid",
"token_endpoint_auth_method": "client_secret_post", "token_endpoint_auth_method": "client_secret_post",
"token_placement": "body", "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 = { GRAPHENE = {
"SCHEMA": "vbv_lernwelt.core.schema.schema", "SCHEMA": "vbv_lernwelt.core.schema.schema",
"SCHEMA_OUTPUT": "../client/src/gql/schema.graphql", "SCHEMA_OUTPUT": "../client/src/gql/schema.graphql",
@ -648,6 +661,26 @@ NOTIFICATIONS_NOTIFICATION_MODEL = "notify.Notification"
# sendgrid (email notifications) # sendgrid (email notifications)
SENDGRID_API_KEY = env("IT_SENDGRID_API_KEY", default="") SENDGRID_API_KEY = env("IT_SENDGRID_API_KEY", default="")
# Datatrans (payment)
# See https://admin.sandbox.datatrans.com/MerchSecurAdmin.jsp
DATATRANS_HMAC_KEY = env("DATATRANS_HMAC_KEY", default="")
# See https://admin.sandbox.datatrans.com/MenuDispatch.jsp?main=1&sub=4
# => echo -n "Username:Password" | base64
DATATRANS_BASIC_AUTH_KEY = env("DATATRANS_BASIC_AUTH_KEY", default="")
if APP_ENVIRONMENT.startswith("prod"):
DATATRANS_API_ENDPOINT = "https://api.datatrans.com"
DATATRANS_PAY_URL = "https://pay.datatrans.com"
else:
DATATRANS_API_ENDPOINT = "https://api.sandbox.datatrans.com"
DATATRANS_PAY_URL = "https://pay.sandbox.datatrans.com"
# Only for debugging the webhook (locally)
DATATRANS_DEBUG_WEBHOOK_OVERWRITE = env(
"DATATRANS_DEBUG_WEBHOOK_OVERWRITE", default=None
)
# S3 BUCKET CONFIGURATION # S3 BUCKET CONFIGURATION
FILE_UPLOAD_STORAGE = env("FILE_UPLOAD_STORAGE", default="s3") # local | s3 FILE_UPLOAD_STORAGE = env("FILE_UPLOAD_STORAGE", default="s3") # local | s3

View File

@ -11,6 +11,8 @@ from django.views.decorators.csrf import csrf_exempt
from django_ratelimit.exceptions import Ratelimited from django_ratelimit.exceptions import Ratelimited
from graphene_django.views import GraphQLView from graphene_django.views import GraphQLView
from vbv_lernwelt.api.directory import list_entities
from vbv_lernwelt.api.user import me_user_view
from vbv_lernwelt.api.user import get_cockpit_type from vbv_lernwelt.api.user import get_cockpit_type
from vbv_lernwelt.assignment.views import request_assignment_completion_status from vbv_lernwelt.assignment.views import request_assignment_completion_status
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
@ -19,7 +21,6 @@ from vbv_lernwelt.core.views import (
check_rate_limit, check_rate_limit,
cypress_reset_view, cypress_reset_view,
generate_web_component_icons, generate_web_component_icons,
me_user_view,
permission_denied_view, permission_denied_view,
rate_limit_exceeded_view, rate_limit_exceeded_view,
vue_home, vue_home,
@ -99,6 +100,8 @@ urlpatterns = [
# user management # user management
path("sso/", include("vbv_lernwelt.sso.urls")), path("sso/", include("vbv_lernwelt.sso.urls")),
re_path(r'api/core/me/$', me_user_view, name='me_user_view'), re_path(r'api/core/me/$', me_user_view, name='me_user_view'),
re_path(r'api/core/entities/$', list_entities, name='list_entities'),
re_path(r'api/core/login/$', django_view_authentication_exempt(vue_login), re_path(r'api/core/login/$', django_view_authentication_exempt(vue_login),
name='vue_login'), name='vue_login'),
re_path(r'api/core/logout/$', vue_logout, name='vue_logout'), re_path(r'api/core/logout/$', vue_logout, name='vue_logout'),
@ -174,6 +177,9 @@ urlpatterns = [
path(r'api/core/edoniq-test/export-users-trainers/', export_students_and_trainers, path(r'api/core/edoniq-test/export-users-trainers/', export_students_and_trainers,
name='edoniq_export_students_and_trainers'), name='edoniq_export_students_and_trainers'),
# shop
path("api/shop/", include("vbv_lernwelt.shop.urls")),
# importer # importer
path( path(
r"server/importer/coursesession-trainer-import/", r"server/importer/coursesession-trainer-import/",

View File

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

View File

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

View File

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

View File

@ -8,6 +8,29 @@ from vbv_lernwelt.course.models import Course, CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.learning_mentor.models import LearningMentor from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.core.serializers import UserSerializer
@api_view(["GET", "PUT"])
def me_user_view(request):
if not request.user.is_authenticated:
return Response(status=403)
if request.method == "GET":
return Response(UserSerializer(request.user).data)
if request.method == "PUT":
serializer = UserSerializer(
request.user,
data=request.data,
partial=True,
)
if serializer.is_valid():
serializer.save()
return Response(UserSerializer(request.user).data)
return Response(status=400)
@api_view(["GET"]) @api_view(["GET"])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])

View File

@ -2,7 +2,7 @@ from django.contrib import admin
from django.contrib.auth import admin as auth_admin, get_user_model from django.contrib.auth import admin as auth_admin, get_user_model
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from vbv_lernwelt.core.models import JobLog from vbv_lernwelt.core.models import JobLog, Organisation
from vbv_lernwelt.core.utils import pretty_print_json from vbv_lernwelt.core.utils import pretty_print_json
User = get_user_model() User = get_user_model()
@ -43,6 +43,7 @@ class UserAdmin(auth_admin.UserAdmin):
}, },
), ),
(_("Important dates"), {"fields": ("last_login", "date_joined")}), (_("Important dates"), {"fields": ("last_login", "date_joined")}),
(_("Profile"), {"fields": ("organisation", "language")}),
(_("Additional data"), {"fields": ("additional_json_data",)}), (_("Additional data"), {"fields": ("additional_json_data",)}),
) )
list_display = [ list_display = [
@ -78,3 +79,13 @@ class JobLogAdmin(LogAdmin):
if obj.ended: if obj.ended:
return (obj.ended - obj.started).seconds // 60 return (obj.ended - obj.started).seconds // 60
return None return None
@admin.register(Organisation)
class OrganisationAdmin(admin.ModelAdmin):
list_display = (
"organisation_id",
"name_de",
"name_fr",
"name_it",
)

View File

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

View File

@ -32,3 +32,115 @@ def find_available_slug(requested_slug, ignore_page_id=None):
number += 1 number += 1
return slug return slug
orgs = {
1: {"de": "andere Broker", "fr": "autres Broker", "it": "altre Broker"},
2: {
"de": "andere Krankenversicherer",
"fr": "autres assureurs santé",
"it": "altre assicurazioni sanitarie",
},
3: {
"de": "andere Privatversicherer",
"fr": "autres Assurance privée",
"it": "altre Assicurazione privato",
},
4: {"de": "Allianz Suisse", "fr": "Allianz Suisse", "it": "Allianz Suisse"},
5: {"de": "AON", "fr": "AON", "it": "AON"},
6: {
"de": "AXA Winterthur",
"fr": "AXA Assurances SA",
"it": "AXA Assicurazioni SA",
},
7: {"de": "Baloise", "fr": "Baloise", "it": "Baloise"},
8: {
"de": "CAP Rechtsschutz",
"fr": "CAP Protection juridique",
"it": "CAP Protezione giuridica",
},
9: {
"de": "Coop Rechtsschutz",
"fr": "Coop Protection juridique",
"it": "Coop Protezione giuridica",
},
10: {"de": "CSS", "fr": "CSS", "it": "CSS"},
11: {"de": "Die Mobiliar", "fr": "La Mobilière", "it": "La Mobiliare"},
12: {
"de": "Emmental Versicherung",
"fr": "Emmental Assurance",
"it": "Emmental Assicurazione",
},
13: {
"de": "GENERALI Versicherungen",
"fr": "Generali Assurances",
"it": "Generali Assicurazioni",
},
14: {"de": "Groupe Mutuel", "fr": "GROUPE MUTUEL", "it": "GROUPE MUTUEL"},
15: {"de": "Helsana", "fr": "Helsana", "it": "Helsana"},
16: {"de": "Helvetia", "fr": "Helvetia", "it": "Helvetia"},
17: {"de": "Kessler & Co AG", "fr": "Kessler & Co AG", "it": "Kessler & Co AG"},
18: {
"de": "Orion Rechtsschutz Versicherung",
"fr": "Orion Protection juridique",
"it": "Orion Protezione giuridica",
},
19: {"de": "PAX", "fr": "PAX", "it": "PAX"},
20: {"de": "Sanitas", "fr": "Sanitas", "it": "Sanitas"},
21: {"de": "SUVA", "fr": "SUVA", "it": "SUVA"},
22: {"de": "Swica", "fr": "Swica", "it": "Swica"},
23: {"de": "Swiss Life", "fr": "Swiss Life", "it": "Swiss Life"},
24: {"de": "Swiss Re", "fr": "Swiss Re", "it": "Swiss Re"},
25: {
"de": "Visana Services AG",
"fr": "Visana Services SA",
"it": "Visana Services SA",
},
26: {
"de": "VZ VermögensZentrum AG",
"fr": "VZ VermögensZentrum AG",
"it": "VZ VermögensZentrum AG",
},
27: {
"de": "Würth Financial Services AG",
"fr": "Würth Financial Services SA",
"it": "Würth Financial Services SA",
},
28: {"de": "Zürich", "fr": "Zurich", "it": "Zurigo"},
29: {"de": "VBV", "fr": "AFA", "it": "AFA"},
30: {"de": "Vaudoise", "fr": "Vaudoise", "it": "Vaudoise"},
31: {
"de": "Keine Firmenzugehörigkeit",
"fr": "Pas d'appartenance à une entreprise",
"it": "Nessuna affiliazione aziendale",
},
}
def add_organisations(apps=None, schema_editor=None):
if apps is None:
# pylint: disable=import-outside-toplevel
from vbv_lernwelt.core.models import Organisation
else:
Organisation = apps.get_model("core", "Organisation")
for org_id, org_data in orgs.items():
Organisation.objects.get_or_create(
organisation_id=org_id,
name_de=org_data["de"],
name_fr=org_data["fr"],
name_it=org_data["it"],
)
def remove_organisations(apps=None, schema_editor=None):
if apps is None:
# pylint: disable=import-outside-toplevel
from vbv_lernwelt.core.models import Organisation
else:
Organisation = apps.get_model("core", "Organisation")
for org_id in orgs.keys():
Organisation.objects.filter(
organisation_id=org_id,
).delete()

View File

@ -5,6 +5,21 @@ from django.db import models
from django.db.models import JSONField from django.db.models import JSONField
class Organisation(models.Model):
organisation_id = models.IntegerField(primary_key=True)
name_de = models.CharField(max_length=255)
name_fr = models.CharField(max_length=255)
name_it = models.CharField(max_length=255)
def __str__(self):
return f"{self.name_de} ({self.organisation_id})"
class Meta:
verbose_name = "Organisation"
verbose_name_plural = "Organisations"
ordering = ["organisation_id"]
class User(AbstractUser): class User(AbstractUser):
""" """
Default custom user model for VBV Lernwelt. Default custom user model for VBV Lernwelt.
@ -29,6 +44,10 @@ class User(AbstractUser):
additional_json_data = JSONField(default=dict, blank=True) additional_json_data = JSONField(default=dict, blank=True)
language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="de") language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="de")
organisation = models.ForeignKey(
Organisation, on_delete=models.SET_NULL, null=True, blank=True
)
class SecurityRequestResponseLog(models.Model): class SecurityRequestResponseLog(models.Model):
label = models.CharField(max_length=255, blank=True, default="") label = models.CharField(max_length=255, blank=True, default="")

View File

@ -3,7 +3,7 @@ from typing import List
from rest_framework import serializers from rest_framework import serializers
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from vbv_lernwelt.core.models import User from vbv_lernwelt.core.models import Organisation, User
from vbv_lernwelt.course.models import CourseSessionUser from vbv_lernwelt.course.models import CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.course_session_group.models import CourseSessionGroup
@ -25,6 +25,7 @@ class UserSerializer(serializers.ModelSerializer):
"email", "email",
"username", "username",
"avatar_url", "avatar_url",
"organisation",
"is_superuser", "is_superuser",
"course_session_experts", "course_session_experts",
"language", "language",
@ -52,3 +53,9 @@ class UserSerializer(serializers.ModelSerializer):
) )
return [str(_id) for _id in (supervisor_in_session_ids | expert_in_session_ids)] return [str(_id) for _id in (supervisor_in_session_ids | expert_in_session_ids)]
class OrganisationSerializer(serializers.ModelSerializer):
class Meta:
model = Organisation
fields = "__all__"

View File

@ -7,7 +7,12 @@ import structlog
from django.conf import settings from django.conf import settings
from django.contrib.auth import authenticate, login, logout from django.contrib.auth import authenticate, login, logout
from django.core.management import call_command from django.core.management import call_command
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse from django.http import (
HttpResponse,
HttpResponseRedirect,
JsonResponse,
StreamingHttpResponse,
)
from django.shortcuts import render from django.shortcuts import render
from django.template import loader from django.template import loader
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
@ -31,12 +36,21 @@ logger = structlog.get_logger(__name__)
@ensure_csrf_cookie @ensure_csrf_cookie
def vue_home(request, *args): def vue_home(request, *args):
if settings.IT_SERVE_VUE: if settings.IT_SERVE_VUE:
from gunicorn.util import is_hoppish
try: try:
res = requests.get(f"{settings.IT_SERVE_VUE_URL}{request.get_full_path()}") path = request.get_full_path()
content = res.text res = requests.get(f"{settings.IT_SERVE_VUE_URL}{path}", stream=True)
headers = res.headers response = StreamingHttpResponse(
content_type = headers.get("content-type", "text/html") streaming_content=(chunk for chunk in res.iter_content(4096)),
return HttpResponse(content, content_type=content_type) content_type=res.headers.get("Content-Type", "text/html"),
status=res.status_code,
)
for name, value in res.headers.items():
if not is_hoppish(name):
response[name] = value
return response
except Exception as e: except Exception as e:
return HttpResponse( return HttpResponse(
f"Can not connect to vue dev server at {settings.IT_SERVE_VUE_URL}: {e}" f"Can not connect to vue dev server at {settings.IT_SERVE_VUE_URL}: {e}"
@ -76,27 +90,6 @@ def vue_login(request):
) )
@api_view(["GET", "PUT"])
def me_user_view(request):
if not request.user.is_authenticated:
return Response(status=403)
if request.method == "GET":
return Response(UserSerializer(request.user).data)
if request.method == "PUT":
serializer = UserSerializer(
request.user,
data={"language": request.data.get("language", "de")},
partial=True,
)
if serializer.is_valid():
serializer.save()
return Response(UserSerializer(request.user).data)
return Response(status=400)
@api_view(["POST"]) @api_view(["POST"])
def vue_logout(request): def vue_logout(request):
logout(request) logout(request)

View File

@ -58,8 +58,8 @@ from vbv_lernwelt.learnpath.tests.learning_path_factories import (
) )
def create_course(title: str) -> Tuple[Course, CoursePage]: def create_course(title: str, _id=None) -> Tuple[Course, CoursePage]:
course = Course.objects.create(title=title, category_name="Handlungsfeld") course = Course.objects.create(id=_id, title=title, category_name="Handlungsfeld")
course_page = CoursePageFactory( course_page = CoursePageFactory(
title="Test Lehrgang", title="Test Lehrgang",

View File

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

View File

@ -216,7 +216,7 @@ def create_or_update_user(
sso_id: str = None, sso_id: str = None,
contract_number: str = "", contract_number: str = "",
date_of_birth: str = "", date_of_birth: str = "",
): ) -> User:
logger.debug( logger.debug(
"create_or_update_user", "create_or_update_user",
email=email, email=email,

View File

@ -62,6 +62,13 @@ class EmailTemplate(Enum):
# VBV - Neues Feedback für Circle # VBV - Neues Feedback für Circle
NEW_FEEDBACK = {"de": "d-40fb94d5149949e7b8e7ddfcf0fcfdde"} NEW_FEEDBACK = {"de": "d-40fb94d5149949e7b8e7ddfcf0fcfdde"}
# Versicherungsvermittler (after buying a course)
WELCOME_MAIL_VV = {
"de": "d-308a72c779b74c8487cdec03c772ad13",
"fr": "d-1a0958c7798c4dd18f730491e920eab5",
"it": "d-0882ec9c92f64312b9f358481a943c9a",
}
# VBV - Lernbegleitung Einladung # VBV - Lernbegleitung Einladung
LEARNING_MENTOR_INVITATION = { LEARNING_MENTOR_INVITATION = {
"de": "d-8c862afde62748b6b8410887eeee89d8", "de": "d-8c862afde62748b6b8410887eeee89d8",

View File

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

View File

View File

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

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ShopConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "vbv_lernwelt.shop"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,5 @@
from authlib.integrations.django_client import OAuth from authlib.integrations.django_client import OAuth
from django.conf import settings
# # https://docs.authlib.org/en/latest/client/frameworks.html#frameworks-clients
oauth = OAuth() oauth = OAuth()
oauth.register( oauth.register(name="signup")
name=settings.OAUTH["client_name"], oauth.register(name="signin")
client_id=settings.OAUTH["client_id"],
client_secret=settings.OAUTH["client_secret"],
request_token_url=None,
request_token_params=None,
authorize_params=settings.OAUTH["authorize_params"],
client_kwargs=settings.OAUTH["client_kwargs"],
server_metadata_url=settings.OAUTH["server_metadata_url"],
)

View File

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

View File

@ -6,10 +6,11 @@ from . import views
app_name = "sso" app_name = "sso"
urlpatterns = [ urlpatterns = [
path(r"login/", django_view_authentication_exempt(views.login), name="login"), path(r"login/", django_view_authentication_exempt(views.signin), name="login"),
path(r"signup/", django_view_authentication_exempt(views.signup), name="signup"),
path( path(
r"callback/", r"callback/",
django_view_authentication_exempt(views.authorize), django_view_authentication_exempt(views.authorize_signin),
name="authorize", name="authorize",
), ),
] ]

View File

@ -1,3 +1,6 @@
import base64
import json
import structlog as structlog import structlog as structlog
from authlib.integrations.base_client import OAuthError from authlib.integrations.base_client import OAuthError
from django.conf import settings from django.conf import settings
@ -5,53 +8,116 @@ from django.contrib.auth import login as dj_login
from django.shortcuts import redirect from django.shortcuts import redirect
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course_session.utils import has_course_session_user_vv
from vbv_lernwelt.importer.services import create_or_update_user from vbv_lernwelt.importer.services import create_or_update_user
from vbv_lernwelt.sso.client import oauth from vbv_lernwelt.sso.client import oauth
from vbv_lernwelt.sso.jwt import decode_jwt from vbv_lernwelt.sso.jwt import decode_jwt
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
OAUTH_FAIL_REDIRECT = "login-error"
def signup(request):
course_param = request.GET.get("course")
next_param = request.GET.get("next")
def login(request): state_json = json.dumps({"course": course_param, "next": next_param})
oauth_client = oauth.create_client(settings.OAUTH["client_name"]) state_encoded = base64.urlsafe_b64encode(state_json.encode()).decode()
redirect_uri = settings.OAUTH["local_redirect_uri"]
language = request.GET.get("lang", "de")
return oauth_client.authorize_redirect(request, redirect_uri, lang=language)
redirect_uri = settings.OAUTH_SIGNUP_REDIRECT_URI
def authorize(request): logger.debug(
try: f"SSO Signup (course={course_param}, next={next_param})",
logger.debug(request, label="sso") sso_signup_redirect_uri=redirect_uri,
token = getattr(oauth, settings.OAUTH["client_name"]).authorize_access_token(
request
) )
decoded_token = decode_jwt(token["id_token"])
# logger.debug(label="sso", decoded_token=decoded_token) return oauth.signup.authorize_redirect(
request, redirect_uri, state=state_encoded, lang=request.GET.get("lang", "de")
)
def signin(request):
"""
Called directly from the frontend AND as a redirect from signup!
"""
# redirect from signup
if "state" in request.GET:
state_decoded = json.loads(
base64.urlsafe_b64decode(request.GET.get("state").encode()).decode()
)
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: except OAuthError as e:
logger.error(e, exc_info=True, label="sso") logger.error(e, exc_info=True, label="sso")
if not settings.DEBUG: if not settings.DEBUG:
capture_exception(e) capture_exception(e)
return redirect(f"/{OAUTH_FAIL_REDIRECT}?state=someerror") # to be defined return redirect("/")
id_token = decode_jwt(jwt_token["id_token"])
state = json.loads(
base64.urlsafe_b64decode(request.GET.get("state").encode()).decode()
)
course = state.get("course")
next_url = state.get("next")
logger.debug(
f"SSO Authorize (course={course}, next={next_url}",
sso_authorize_id_token=id_token,
)
user_data = _user_data_from_token_data(decoded_token)
user = create_or_update_user( user = create_or_update_user(
email=user_data.get("email").lower(), email=id_token.get("email", ""),
sso_id=user_data.get("sso_id"), sso_id=id_token.get("oid"),
first_name=user_data.get("first_name", ""), first_name=id_token.get("given_name", ""),
last_name=user_data.get("last_name", ""), last_name=id_token.get("family_name", ""),
) )
dj_login(request, user) dj_login(request, user)
return redirect(f"/")
return get_redirect_uri(user=user, course=course, next_url=next_url)
def _user_data_from_token_data(token: dict) -> dict:
first_email = token.get("emails", [""])[0]
return {
"first_name": token.get("given_name", ""),
"last_name": token.get("family_name", ""),
"email": first_email,
"sso_id": token.get("oid"),
}

View File

@ -1,3 +1,3 @@
<svg viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.99997 0.500065C5.87826 0.500065 3.84354 1.34291 2.34313 2.84319C0.842877 4.34358 0 6.37855 0 8.50003C0 10.6215 0.842844 12.6565 2.34313 14.1569C3.84351 15.6571 5.87849 16.5 7.99997 16.5C10.1214 16.5 12.1564 15.6572 13.6568 14.1569C15.1571 12.6565 15.9999 10.6215 15.9999 8.50003C15.9999 7.09568 15.6303 5.71622 14.9281 4.50002C14.226 3.28382 13.2161 2.27398 11.9999 1.57181C10.7837 0.869634 9.40425 0.5 7.9999 0.5L7.99997 0.500065ZM14.2315 5.64294H11.5087C11.1104 4.2353 10.4191 2.92763 9.48007 1.80587C10.514 2.03661 11.4806 2.50308 12.3047 3.16848C13.1288 3.83405 13.7883 4.68085 14.2315 5.64298V5.64294ZM7.96862 15.0885C6.92078 14.0448 6.13802 12.7653 5.68571 11.3573H10.3143C9.8611 12.7657 9.07743 14.0451 8.02857 15.0885C8.01059 15.1014 7.98648 15.1014 7.96862 15.0885H7.96862ZM5.39718 10.2144C5.18761 9.0811 5.18761 7.9191 5.39718 6.78581H10.6028C10.8123 7.9191 10.8123 9.0811 10.6028 10.2144H5.39718ZM1.1428 8.5001C1.1419 7.92178 1.21486 7.34564 1.35989 6.78581H4.23706C4.05058 7.92103 4.05058 9.07917 4.23706 10.2144H1.35989C1.21486 9.65455 1.1419 9.07842 1.1428 8.5001ZM7.99997 1.90023C8.01004 1.89628 8.02127 1.89628 8.03135 1.90023C9.08072 2.94729 9.86361 4.23086 10.3143 5.64294H5.68567C6.13886 4.23451 6.92254 2.95506 7.97139 1.91166C7.97917 1.90439 7.98938 1.90031 7.99997 1.90018L7.99997 1.90023ZM11.7629 6.78581H14.6401C14.9296 7.91032 14.9296 9.08988 14.6401 10.2144H11.7629C11.9494 9.07917 11.9494 7.92103 11.7629 6.78581ZM6.52012 1.80587C5.5821 2.92832 4.89089 4.23572 4.49154 5.64294H1.76869C2.21193 4.68082 2.87139 3.83402 3.69549 3.16845C4.51959 2.50301 5.48619 2.03656 6.52012 1.80583V1.80587ZM1.76876 11.3573H4.49161C4.88982 12.7649 5.58115 14.0726 6.52019 15.1943C5.48626 14.9636 4.51966 14.4971 3.69556 13.8317C2.87146 13.1661 2.21199 12.3193 1.76876 11.3572V11.3573ZM9.48308 15.1943C10.4199 14.0716 11.1101 12.7642 11.5087 11.3573H14.2316C13.7883 12.3194 13.1289 13.1662 12.3048 13.8317C11.4807 14.4972 10.5141 14.9636 9.48014 15.1944L9.48308 15.1943Z" fill="#0A0A0A"/> <path d="M7.99997 0.500065C5.87826 0.500065 3.84354 1.34291 2.34313 2.84319C0.842877 4.34358 0 6.37855 0 8.50003C0 10.6215 0.842844 12.6565 2.34313 14.1569C3.84351 15.6571 5.87849 16.5 7.99997 16.5C10.1214 16.5 12.1564 15.6572 13.6568 14.1569C15.1571 12.6565 15.9999 10.6215 15.9999 8.50003C15.9999 7.09568 15.6303 5.71622 14.9281 4.50002C14.226 3.28382 13.2161 2.27398 11.9999 1.57181C10.7837 0.869634 9.40425 0.5 7.9999 0.5L7.99997 0.500065ZM14.2315 5.64294H11.5087C11.1104 4.2353 10.4191 2.92763 9.48007 1.80587C10.514 2.03661 11.4806 2.50308 12.3047 3.16848C13.1288 3.83405 13.7883 4.68085 14.2315 5.64298V5.64294ZM7.96862 15.0885C6.92078 14.0448 6.13802 12.7653 5.68571 11.3573H10.3143C9.8611 12.7657 9.07743 14.0451 8.02857 15.0885C8.01059 15.1014 7.98648 15.1014 7.96862 15.0885H7.96862ZM5.39718 10.2144C5.18761 9.0811 5.18761 7.9191 5.39718 6.78581H10.6028C10.8123 7.9191 10.8123 9.0811 10.6028 10.2144H5.39718ZM1.1428 8.5001C1.1419 7.92178 1.21486 7.34564 1.35989 6.78581H4.23706C4.05058 7.92103 4.05058 9.07917 4.23706 10.2144H1.35989C1.21486 9.65455 1.1419 9.07842 1.1428 8.5001ZM7.99997 1.90023C8.01004 1.89628 8.02127 1.89628 8.03135 1.90023C9.08072 2.94729 9.86361 4.23086 10.3143 5.64294H5.68567C6.13886 4.23451 6.92254 2.95506 7.97139 1.91166C7.97917 1.90439 7.98938 1.90031 7.99997 1.90018L7.99997 1.90023ZM11.7629 6.78581H14.6401C14.9296 7.91032 14.9296 9.08988 14.6401 10.2144H11.7629C11.9494 9.07917 11.9494 7.92103 11.7629 6.78581ZM6.52012 1.80587C5.5821 2.92832 4.89089 4.23572 4.49154 5.64294H1.76869C2.21193 4.68082 2.87139 3.83402 3.69549 3.16845C4.51959 2.50301 5.48619 2.03656 6.52012 1.80583V1.80587ZM1.76876 11.3573H4.49161C4.88982 12.7649 5.58115 14.0726 6.52019 15.1943C5.48626 14.9636 4.51966 14.4971 3.69556 13.8317C2.87146 13.1661 2.21199 12.3193 1.76876 11.3572V11.3573ZM9.48308 15.1943C10.4199 14.0716 11.1101 12.7642 11.5087 11.3573H14.2316C13.7883 12.3194 13.1289 13.1662 12.3048 13.8317C11.4807 14.4972 10.5141 14.9636 9.48014 15.1944L9.48308 15.1943Z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -0,0 +1,5 @@
<svg viewBox="0 0 409 202" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M0.114263 201.863L0.101562 196.988C2.33496 196.982 54.8204 196.841 55.8238 196.841C65.4971 196.841 76.6661 194.543 84.2774 190.988C101.236 183.066 110.686 167.54 120.69 151.103C135.026 127.548 149.85 103.193 188.16 103.193C194.208 103.193 199.64 103.3 204.502 103.531C208.104 100.723 212.886 98.8203 218.595 98.0698C221.383 97.7036 225.217 96.9643 228.082 95.5058C227.509 95.2807 226.939 95.0522 226.375 94.8193C212.505 89.1123 202.784 81.7441 201.006 75.5908C199.839 71.5737 200.806 60.5307 200.92 59.2851C201.036 58.0297 202.088 57.0693 203.348 57.0693C229.683 57.0693 234.675 52.3359 234.675 43.6005C234.675 35.1371 227.481 33.8734 223.192 33.8734C215.798 33.8734 205.574 37.8797 200.338 45.537C199.937 46.1244 199.298 46.5072 198.59 46.5844C197.888 46.664 197.176 46.4257 196.658 45.9384L177.728 28.1669C176.834 27.3275 176.702 25.9545 177.421 24.9608C188.727 9.3311 206.945 0 226.154 0C248.48 0 271.082 14.0137 271.082 40.7969C271.082 61.4097 257.454 77.2012 236.94 80.6983V90.4063C236.94 91.3272 236.819 92.2989 236.512 93.2803C248.977 97.3159 264.505 100.218 281.772 100.218H408.926V105.093H281.772C265.039 105.093 248.27 102.441 233.711 97.5513C231.132 99.9077 226.678 101.925 219.231 102.904C217.198 103.171 215.319 103.599 213.606 104.174C233.502 106.134 240.829 111.233 240.829 121.269C240.829 132.634 232.226 139.978 218.913 139.978C206.419 139.978 196.997 130.965 196.997 119.014C196.997 114.961 198.087 111.322 200.113 108.246C196.501 108.129 192.53 108.068 188.16 108.068C152.59 108.068 139.118 130.203 124.855 153.637C114.914 169.97 104.635 186.859 86.3407 195.406C78.1337 199.24 66.1547 201.716 55.8236 201.716C54.8265 201.716 0.661663 201.862 0.114263 201.863ZM206.196 108.514C203.394 111.249 201.873 114.822 201.873 119.014C201.873 128.337 209.039 135.103 218.913 135.103C227.151 135.103 235.954 131.468 235.954 121.269C235.954 115.412 233.361 110.059 206.196 108.514ZM205.599 61.9351C205.288 66.336 205.107 72.2305 205.689 74.2339C207.068 79.0064 216.759 86.1065 231.879 91.6787C232 91.2788 232.065 90.8545 232.065 90.4062V78.5952C232.065 77.3667 232.979 76.3301 234.198 76.1767C253.642 73.7246 266.207 59.8373 266.207 40.7968C266.207 23.496 253.673 4.8759 226.154 4.8759C209.217 4.8759 193.115 12.7753 182.659 26.1088L198.041 40.5492C203.933 33.7372 214.05 28.9974 223.192 28.9974C233.282 28.9974 239.55 34.5931 239.55 43.6004C239.55 58.4339 227.142 61.7324 205.599 61.9351Z"
fill="#0A0A0A"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -7,6 +7,7 @@ server/vbv_lernwelt/notify/email/email_services.py
server/vbv_lernwelt/static/ server/vbv_lernwelt/static/
server/vbv_lernwelt/media/ server/vbv_lernwelt/media/
server/vbv_lernwelt/edoniq_test/certificates/test.key server/vbv_lernwelt/edoniq_test/certificates/test.key
server/vbv_lernwelt/shop/tests/test_datatrans_signature.py
server/vbv_lernwelt/shop/tests/test_create_signature.py server/vbv_lernwelt/shop/tests/test_create_signature.py
supabase.md supabase.md
scripts/supabase/init.sql scripts/supabase/init.sql