Merged in feature/abacus-export (pull request #335)

Feature/abacus export

Approved-by: Christian Cueni
This commit is contained in:
Daniel Egger 2024-06-20 06:46:15 +00:00 committed by Christian Cueni
commit ceb7d07f07
60 changed files with 2258 additions and 1058 deletions

3
.gitignore vendored
View File

@ -277,10 +277,11 @@ cypress/test-reports
git-crypt-encrypted-files-check.txt git-crypt-encrypted-files-check.txt
/server/vbv_lernwelt/static/css/tailwind.css /server/vbv_lernwelt/static/css/tailwind.css
/server/vbv_lernwelt/static/vue/ /server/vbv_lernwelt/static/vue/
/server/vbv_lernwelt/static/storybook /server/vbv_lernwelt/static/storybook
/server/vbv_lernwelt/templates/vue/index.html /server/vbv_lernwelt/templates/vue/index.html
/server/vbv_lernwelt/media /server/vbv_lernwelt/media
/client/src/gql/dist/minifiedSchema.json /client/src/gql/dist/minifiedSchema.json
/sftptest/

View File

@ -4,12 +4,12 @@ import { useEntities } from "@/services/entities";
const props = defineProps<{ const props = defineProps<{
modelValue: { modelValue: {
company_name: string; organisation_detail_name: string;
company_street: string; organisation_street: string;
company_street_number: string; organisation_street_number: string;
company_postal_code: string; organisation_postal_code: string;
company_city: string; organisation_city: string;
company_country: string; organisation_country_code: string;
}; };
}>(); }>();
@ -39,7 +39,7 @@ const orgAddress = computed({
<div class="mt-2"> <div class="mt-2">
<input <input
id="company-name" id="company-name"
v-model="orgAddress.company_name" v-model="orgAddress.organisation_detail_name"
type="text" type="text"
required required
name="company-name" name="company-name"
@ -57,7 +57,7 @@ const orgAddress = computed({
<div class="mt-2"> <div class="mt-2">
<input <input
id="company-street-address" id="company-street-address"
v-model="orgAddress.company_street" v-model="orgAddress.organisation_street"
type="text" type="text"
required required
name="street-address" name="street-address"
@ -77,7 +77,7 @@ const orgAddress = computed({
<div class="mt-2"> <div class="mt-2">
<input <input
id="company-street-number" id="company-street-number"
v-model="orgAddress.company_street_number" v-model="orgAddress.organisation_street_number"
name="street-number" name="street-number"
type="text" type="text"
autocomplete="street-number" autocomplete="street-number"
@ -95,7 +95,7 @@ const orgAddress = computed({
<div class="mt-2"> <div class="mt-2">
<input <input
id="company-postal-code" id="company-postal-code"
v-model="orgAddress.company_postal_code" v-model="orgAddress.organisation_postal_code"
type="text" type="text"
required required
name="postal-code" name="postal-code"
@ -115,7 +115,7 @@ const orgAddress = computed({
<div class="mt-2"> <div class="mt-2">
<input <input
id="company-city" id="company-city"
v-model="orgAddress.company_city" v-model="orgAddress.organisation_city"
type="text" type="text"
name="city" name="city"
required required
@ -135,13 +135,17 @@ const orgAddress = computed({
<div class="mt-2"> <div class="mt-2">
<select <select
id="company-country" id="company-country"
v-model="orgAddress.company_country" v-model="orgAddress.organisation_country_code"
required required
name="country" name="country"
autocomplete="country-name" 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" 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"> <option
v-for="country in countries"
:key="country.country_code"
:value="country.country_code"
>
{{ country.name }} {{ country.name }}
</option> </option>
</select> </select>

View File

@ -10,7 +10,7 @@ const props = defineProps<{
street_number: string; street_number: string;
postal_code: string; postal_code: string;
city: string; city: string;
country: string; country_code: string;
}; };
}>(); }>();
@ -147,13 +147,17 @@ const address = computed({
<div class="mt-2"> <div class="mt-2">
<select <select
id="country" id="country"
v-model="address.country" v-model="address.country_code"
required required
name="country" name="country"
autocomplete="country-name" 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" 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"> <option
v-for="country in countries"
:key="country.country_code"
:value="country.country_code"
>
{{ country.name }} {{ country.name }}
</option> </option>
</select> </select>

View File

@ -20,23 +20,25 @@ const formData = ref({
street_number: user.street_number, street_number: user.street_number,
postal_code: user.postal_code, postal_code: user.postal_code,
city: user.city, city: user.city,
country_id: user.country?.id, country_code: user.country?.country_code,
organisation: user.organisation, organisation: user.organisation,
organisation_street: user.organisation_street, organisation_street: user.organisation_street,
organisation_street_number: user.organisation_street_number, organisation_street_number: user.organisation_street_number,
organisation_postal_code: user.organisation_postal_code, organisation_postal_code: user.organisation_postal_code,
organisation_city: user.organisation_city, organisation_city: user.organisation_city,
organisation_country_id: user.organisation_country?.id, organisation_country_code: user.organisation_country?.country_code,
invoice_address: user.invoice_address, invoice_address: user.invoice_address,
}); });
async function save() { async function save() {
const { country_id, organisation_country_id, ...profileData } = formData.value; const { country_code, organisation_country_code, ...profileData } = formData.value;
const typedProfileData: Partial<User> = { ...profileData }; const typedProfileData: Partial<User> = { ...profileData };
typedProfileData.country = countries.value.find((c) => c.id === country_id); typedProfileData.country = countries.value.find(
(c) => c.country_code === country_code
);
typedProfileData.organisation_country = countries.value.find( typedProfileData.organisation_country = countries.value.find(
(c) => c.id === organisation_country_id (c) => c.country_code === organisation_country_code
); );
await user.updateUserProfile(typedProfileData); await user.updateUserProfile(typedProfileData);
@ -219,12 +221,16 @@ async function avatarUpload(e: Event) {
<select <select
id="country" id="country"
v-model="formData.country_id" v-model="formData.country_code"
name="country" name="country"
autocomplete="country-name" autocomplete="country-name"
class="disabled:bg-gray-50 mb-4 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 disabled:cursor-not-allowed disabled:text-gray-500 disabled:ring-gray-200 sm:max-w-sm sm:text-sm sm:leading-6" class="disabled:bg-gray-50 mb-4 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 disabled:cursor-not-allowed disabled:text-gray-500 disabled:ring-gray-200 sm:max-w-sm sm:text-sm sm:leading-6"
> >
<option v-for="country in countries" :key="country.id" :value="country.id"> <option
v-for="country in countries"
:key="country.country_code"
:value="country.country_code"
>
{{ country.name }} {{ country.name }}
</option> </option>
</select> </select>
@ -325,13 +331,17 @@ async function avatarUpload(e: Event) {
<select <select
id="org-country" id="org-country"
v-model="formData.organisation_country_id" v-model="formData.organisation_country_code"
required required
name="org-country" name="org-country"
autocomplete="country-name" autocomplete="country-name"
class="disabled:bg-gray-50 mb-4 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 disabled:cursor-not-allowed disabled:text-gray-500 disabled:ring-gray-200 sm:max-w-sm sm:text-sm sm:leading-6" class="disabled:bg-gray-50 mb-4 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 disabled:cursor-not-allowed disabled:text-gray-500 disabled:ring-gray-200 sm:max-w-sm sm:text-sm sm:leading-6"
> >
<option v-for="country in countries" :key="country.id" :value="country.id"> <option
v-for="country in countries"
:key="country.country_code"
:value="country.country_code"
>
{{ country.name }} {{ country.name }}
</option> </option>
</select> </select>

View File

@ -1,33 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import WizardPage from "@/components/onboarding/WizardPage.vue"; import WizardPage from "@/components/onboarding/WizardPage.vue";
import type { Ref } from "vue"; import { computed, ref } from "vue";
import { computed, ref, watch } from "vue"; import { type User, useUserStore } from "@/stores/user";
import { useUserStore } from "@/stores/user";
import PersonalAddress from "@/components/onboarding/PersonalAddress.vue"; import PersonalAddress from "@/components/onboarding/PersonalAddress.vue";
import OrganisationAddress from "@/components/onboarding/OrganisationAddress.vue"; import OrganisationAddress from "@/components/onboarding/OrganisationAddress.vue";
import { itPost, itPut } from "@/fetchHelpers"; import { itPost } from "@/fetchHelpers";
import { useEntities } from "@/services/entities"; import { useEntities } from "@/services/entities";
import { useDebounceFn, useFetch } from "@vueuse/core";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useTranslation } from "i18next-vue"; import { useTranslation } from "i18next-vue";
import { getVVCourseName } from "./composables"; import { getVVCourseName } from "./composables";
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({ const props = defineProps({
courseType: { courseType: {
type: String, type: String,
@ -37,7 +19,7 @@ const props = defineProps({
const user = useUserStore(); const user = useUserStore();
const route = useRoute(); const route = useRoute();
const { organisations } = useEntities(); const { organisations, countries } = useEntities();
const userOrganisationName = computed(() => { const userOrganisationName = computed(() => {
if (!user.organisation) { if (!user.organisation) {
@ -61,56 +43,27 @@ const paymentError = computed(() => {
}); });
const address = ref({ const address = ref({
first_name: "", first_name: user.first_name,
last_name: "", last_name: user.last_name,
street: "", street: user.street,
street_number: "", street_number: user.street_number,
postal_code: "", postal_code: user.postal_code,
city: "", city: user.city,
country: "", country_code: user.country?.country_code ?? "CH",
company_name: "", organisation_detail_name: user.organisation_detail_name,
company_street: "", organisation_street: user.organisation_street,
company_street_number: "", organisation_street_number: user.organisation_street_number,
company_postal_code: "", organisation_postal_code: user.organisation_postal_code,
company_city: "", organisation_city: user.organisation_city,
company_country: "", organisation_country_code: user.organisation_country?.country_code ?? "CH",
invoice_address: user.invoice_address ?? "prv",
}); });
const useCompanyAddress = ref(false); const useCompanyAddress = ref(user.invoice_address === "org");
const fetchBillingAddress = useFetch("/api/shop/billing-address/").json();
const billingAddressData: Ref<BillingAddressType | null> = fetchBillingAddress.data;
watch(billingAddressData, (newVal) => { const setUseCompanyAddress = (value: boolean) => {
if (newVal) { useCompanyAddress.value = value;
address.value = newVal; address.value.invoice_address = value ? "org" : "prv";
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 = { type FormErrors = {
@ -153,44 +106,59 @@ function validateAddress() {
formErrors.value.personal.push(t("a.Ort")); formErrors.value.personal.push(t("a.Ort"));
} }
if (!address.value.country) { if (!address.value.country_code) {
formErrors.value.personal.push(t("a.Land")); formErrors.value.personal.push(t("a.Land"));
} }
if (useCompanyAddress.value) { if (useCompanyAddress.value) {
if (!address.value.company_name) { if (!address.value.organisation_detail_name) {
formErrors.value.company.push(t("a.Name")); formErrors.value.company.push(t("a.Name"));
} }
if (!address.value.company_street) { if (!address.value.organisation_street) {
formErrors.value.company.push(t("a.Strasse")); formErrors.value.company.push(t("a.Strasse"));
} }
if (!address.value.company_street_number) { if (!address.value.organisation_street_number) {
formErrors.value.company.push(t("a.Hausnummmer")); formErrors.value.company.push(t("a.Hausnummmer"));
} }
if (!address.value.company_postal_code) { if (!address.value.organisation_postal_code) {
formErrors.value.company.push(t("a.PLZ")); formErrors.value.company.push(t("a.PLZ"));
} }
if (!address.value.company_city) { if (!address.value.organisation_city) {
formErrors.value.company.push(t("a.Ort")); formErrors.value.company.push(t("a.Ort"));
} }
if (!address.value.company_country) { if (!address.value.organisation_country_code) {
formErrors.value.company.push(t("a.Land")); formErrors.value.company.push(t("a.Land"));
} }
} }
} }
const executePayment = () => { async function saveAddress() {
validateAddress(); const { country_code, organisation_country_code, ...profileData } = address.value;
const typedProfileData: Partial<User> = { ...profileData };
typedProfileData.country = countries.value.find(
(c) => c.country_code === country_code
);
typedProfileData.organisation_country = countries.value.find(
(c) => c.country_code === organisation_country_code
);
await user.updateUserProfile(typedProfileData);
}
const executePayment = async () => {
validateAddress();
if (formErrors.value.personal.length > 0 || formErrors.value.company.length > 0) { if (formErrors.value.personal.length > 0 || formErrors.value.company.length > 0) {
return; return;
} }
await saveAddress();
// Where the payment page will redirect to after the payment is done: // 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 // 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). // then we'd need to configure this for all environments (including Caprover).
@ -266,7 +234,7 @@ const executePayment = () => {
<button <button
v-if="!useCompanyAddress" v-if="!useCompanyAddress"
class="underline" class="underline"
@click="useCompanyAddress = true" @click="setUseCompanyAddress(true)"
> >
<template v-if="userOrganisationName"> <template v-if="userOrganisationName">
{{ {{
@ -296,7 +264,7 @@ const executePayment = () => {
}} }}
</h3> </h3>
<h3 v-else>{{ $t("a.Rechnungsadresse") }}</h3> <h3 v-else>{{ $t("a.Rechnungsadresse") }}</h3>
<button class="underline" @click="removeCompanyAddress"> <button class="underline" @click="setUseCompanyAddress(false)">
{{ $t("a.Entfernen") }} {{ $t("a.Entfernen") }}
</button> </button>
</div> </div>

View File

@ -8,7 +8,8 @@ export type Organisation = {
}; };
export type Country = { export type Country = {
id: number; country_code: string;
vbv_country_id: number;
name: string; name: string;
}; };

View File

@ -31,16 +31,16 @@ export interface User {
language: AvailableLanguages; language: AvailableLanguages;
course_session_experts: string[]; course_session_experts: string[];
invoice_address: InvoiceAddress | null; invoice_address: InvoiceAddress | null;
street: string | null; street: string;
street_number: string | null; street_number: string;
postal_code: string | null; postal_code: string;
city: string | null; city: string;
country: Country | null; country: Country | null;
organisation_detail_name: string | null; organisation_detail_name: string;
organisation_street: string | null; organisation_street: string;
organisation_street_number: string | null; organisation_street_number: string;
organisation_postal_code: string | null; organisation_postal_code: string;
organisation_city: string | null; organisation_city: string;
organisation_country: Country | null; organisation_country: Country | null;
} }
@ -74,16 +74,16 @@ const initialUserState: User = {
loggedIn: false, loggedIn: false,
language: defaultLanguage, language: defaultLanguage,
invoice_address: "prv", invoice_address: "prv",
street: null, street: "",
street_number: null, street_number: "",
postal_code: null, postal_code: "",
city: null, city: "",
country: null, country: null,
organisation_detail_name: null, organisation_detail_name: "",
organisation_street: null, organisation_street: "",
organisation_street_number: null, organisation_street_number: "",
organisation_postal_code: null, organisation_postal_code: "",
organisation_city: null, organisation_city: "",
organisation_country: null, organisation_country: null,
}; };

Binary file not shown.

Binary file not shown.

View File

@ -1,5 +1,6 @@
encrypted: env_secrets/caprover_myvbv-prod.env encrypted: env_secrets/caprover_myvbv-prod.env
encrypted: env_secrets/caprover_myvbv-stage.env encrypted: env_secrets/caprover_myvbv-stage.env
encrypted: env_secrets/caprover_vbv-develop.env
encrypted: env_secrets/local_chrigu.env encrypted: env_secrets/local_chrigu.env
encrypted: env_secrets/local_daniel.env encrypted: env_secrets/local_daniel.env
encrypted: env_secrets/local_elia.env encrypted: env_secrets/local_elia.env

View File

@ -675,13 +675,20 @@ if APP_ENVIRONMENT.startswith("prod"):
DATATRANS_API_ENDPOINT = "https://api.datatrans.com" DATATRANS_API_ENDPOINT = "https://api.datatrans.com"
DATATRANS_PAY_URL = "https://pay.datatrans.com" DATATRANS_PAY_URL = "https://pay.datatrans.com"
else: else:
DATATRANS_API_ENDPOINT = "https://api.sandbox.datatrans.com" DATATRANS_API_ENDPOINT = env(
DATATRANS_PAY_URL = "https://pay.sandbox.datatrans.com" "DATATRANS_API_ENDPOINT", default="https://api.sandbox.datatrans.com"
)
DATATRANS_PAY_URL = env(
"DATATRANS_PAY_URL", default="https://pay.sandbox.datatrans.com"
)
# default settings for python sftpserver test-server
ABACUS_EXPORT_SFTP_HOST = env("ABACUS_EXPORT_SFTP_HOST", default="localhost")
ABACUS_EXPORT_SFTP_PASSWORD = env("ABACUS_EXPORT_SFTP_PASSWORD", default="admin")
ABACUS_EXPORT_SFTP_PORT = env("ABACUS_EXPORT_SFTP_PORT", default="3373")
ABACUS_EXPORT_SFTP_USERNAME = env("ABACUS_EXPORT_SFTP_USERNAME", default="admin")
# 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
@ -772,7 +779,7 @@ if APP_ENVIRONMENT == "local":
# django-extensions # django-extensions
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration # https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration
INSTALLED_APPS += ["django_extensions", "django_watchfiles"] # noqa F405 INSTALLED_APPS += ["django_extensions"] # noqa F405
else: else:
# not local # not local
# SECURITY # SECURITY

View File

@ -0,0 +1,44 @@
# pylint: disable=unused-wildcard-import,wildcard-import,wrong-import-position
import os
from dotenv import dotenv_values
script_path = os.path.abspath(__file__)
script_dir = os.path.dirname(script_path)
dev_env = dotenv_values(f"{script_dir}/../../../env_secrets/caprover_vbv-develop.env")
os.environ["IT_APP_ENVIRONMENT"] = "local"
os.environ["AWS_S3_SECRET_ACCESS_KEY"] = dev_env.get("AWS_S3_SECRET_ACCESS_KEY")
os.environ["DATATRANS_BASIC_AUTH_KEY"] = dev_env.get("DATATRANS_BASIC_AUTH_KEY")
os.environ["DATATRANS_HMAC_KEY"] = dev_env.get("DATATRANS_HMAC_KEY")
from .base import * # noqa
# GENERAL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
DATABASES["default"]["NAME"] = "vbv_lernwelt_cypress"
# EMAIL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
CYPRESS_TEST = True
# Your stuff...
# ------------------------------------------------------------------------------
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
"anon": "10000/day",
"hour-throttle": "40000/hour",
"day-throttle": "2000000/day",
}
RATELIMIT_ENABLE = False
# Select faster password hasher during tests
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]

View File

@ -23,6 +23,8 @@ EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
WHITENOISE_MANIFEST_STRICT = False WHITENOISE_MANIFEST_STRICT = False
AWS_S3_FILE_OVERWRITE = True AWS_S3_FILE_OVERWRITE = True
ABACUS_EXPORT_SFTP_PORT = 34343
class DisableMigrations(dict): class DisableMigrations(dict):
def __contains__(self, item): def __contains__(self, item):

View File

@ -14,6 +14,9 @@ from .base import * # noqa
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
DATABASES["default"]["NAME"] = "vbv_lernwelt_cypress" DATABASES["default"]["NAME"] = "vbv_lernwelt_cypress"
DATATRANS_API_ENDPOINT = "http://localhost:8001/server/fakeapi/datatrans/api"
DATATRANS_PAY_URL = "http://localhost:8001/server/fakeapi/datatrans/pay"
# EMAIL # EMAIL
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend # https://docs.djangoproject.com/en/dev/ref/settings/#email-backend

View File

@ -68,6 +68,11 @@ from vbv_lernwelt.importer.views import (
) )
from vbv_lernwelt.media_files.views import user_image from vbv_lernwelt.media_files.views import user_image
from vbv_lernwelt.notify.views import email_notification_settings from vbv_lernwelt.notify.views import email_notification_settings
from vbv_lernwelt.shop.datatrans_fake_server import (
fake_datatrans_api_view,
fake_datatrans_pay_view,
)
from wagtail import urls as wagtail_urls from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as media_library_urls from wagtail.documents import urls as media_library_urls
@ -250,6 +255,16 @@ if settings.DEBUG:
# Static file serving when using Gunicorn + Uvicorn for local web socket development # Static file serving when using Gunicorn + Uvicorn for local web socket development
urlpatterns += staticfiles_urlpatterns() urlpatterns += staticfiles_urlpatterns()
if "fakeapi" in settings.DATATRANS_API_ENDPOINT:
urlpatterns += [
re_path(
r"^server/fakeapi/datatrans/api(?P<api_url>.*)$", fake_datatrans_api_view
),
re_path(
r"^server/fakeapi/datatrans/pay(?P<api_url>.*)$", fake_datatrans_pay_view
),
]
# fmt: on # fmt: on

29
server/conftest.py Normal file
View File

@ -0,0 +1,29 @@
import pytest
from _pytest.runner import runtestprotocol
@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(config, items):
parallel_items = []
serial_items = []
for item in items:
if "serial" in item.keywords:
serial_items.append(item)
else:
parallel_items.append(item)
# Modify the collection to run serial tests first
config.serial_items = serial_items
items[:] = parallel_items
@pytest.hookimpl(tryfirst=True)
def pytest_sessionfinish(session, exitstatus):
config = session.config
if hasattr(config, "serial_items") and config.serial_items:
serial_items = config.serial_items
# Run serial tests one by one
for item in serial_items:
runtestprotocol(item, nextitem=None)

View File

@ -0,0 +1,131 @@
import os
import shutil
import tempfile
from datetime import datetime
from io import StringIO
from subprocess import Popen
from time import sleep
import pytest
from django.conf import settings
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.model_utils import add_countries
from vbv_lernwelt.shop.invoice.abacus import abacus_ssh_upload
from vbv_lernwelt.shop.invoice.abacus_sftp_client import AbacusSftpClient
from vbv_lernwelt.shop.tests.factories import CheckoutInformationFactory
@pytest.fixture(scope="module")
def sftp_server():
tmppath = tempfile.mkdtemp()
print(tmppath)
shutil.rmtree(tmppath)
os.mkdir(tmppath)
os.mkdir(os.path.join(tmppath, "debitor"))
os.mkdir(os.path.join(tmppath, "order"))
sftp_server = Popen(
f"sftpserver -p {settings.ABACUS_EXPORT_SFTP_PORT} -l INFO",
shell=True,
cwd=tmppath,
)
sleep(3)
yield tmppath
if sftp_server:
sftp_server.kill()
def test_can_write_file_to_fake_sftp_server(sftp_server):
with AbacusSftpClient() as client:
files = client.listdir(".")
assert set(files) == {"debitor", "order"}
str_file = StringIO()
str_file.write("Hello world\n")
str_file.seek(0)
client.putfo(str_file, "hello.txt")
files = client.listdir(".")
assert set(files) == {"debitor", "order", "hello.txt"}
@pytest.fixture
def setup_abacus_env(sftp_server):
add_countries(small_set=True)
create_default_users()
yield sftp_server
@pytest.mark.django_db
def test_upload_abacus_xml(setup_abacus_env):
tmppath = setup_abacus_env
# set abacus_number before
_pat = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")
_pat.abacus_debitor_number = 60000011
_pat.save()
_ignore_checkout_information = CheckoutInformationFactory(
user=_pat, abacus_order_id=6_000_000_123
)
feuz = User.objects.get(username="andreas.feuz@eiger-versicherungen.ch")
feuz_checkout_info = CheckoutInformationFactory(
user=feuz,
transaction_id="24021508331287484",
first_name="Andreas",
last_name="Feuz",
street="Eggersmatt",
street_number="32",
postal_code="1719",
city="Zumholz",
country_id="CH",
invoice_address="org",
organisation_detail_name="VBV",
organisation_street="Laupenstrasse",
organisation_street_number="10",
organisation_postal_code="3000",
organisation_city="Bern",
organisation_country_id="CH",
)
feuz_checkout_info.created_at = datetime(2024, 2, 15, 8, 33, 12, 0)
abacus_ssh_upload(feuz_checkout_info)
# check if files got created
debitor_filepath = os.path.join(tmppath, "debitor/myVBV_debi_60000012.xml")
assert os.path.exists(debitor_filepath)
with open(debitor_filepath) as debitor_file:
debi_content = debitor_file.read()
assert "<CustomerNumber>60000012</CustomerNumber>" in debi_content
assert "<Email>andreas.feuz@eiger-versicherungen.ch</Email>" in debi_content
order_filepath = os.path.join(
tmppath, "order/myVBV_orde_60000012_20240215083312_6000000124.xml"
)
assert os.path.exists(order_filepath)
with open(order_filepath) as order_file:
order_content = order_file.read()
assert (
"<ReferencePurchaseOrder>24021508331287484</ReferencePurchaseOrder>"
in order_content
)
assert "<CustomerNumber>60000012</CustomerNumber>" in order_content
feuz_checkout_info.refresh_from_db()
assert feuz_checkout_info.abacus_ssh_upload_done
# calling `abacus_ssh_upload` a second time will not upload files again...
os.remove(debitor_filepath)
os.remove(order_filepath)
abacus_ssh_upload(feuz_checkout_info)
debitor_filepath = os.path.join(tmppath, "debitor/myVBV_debi_60000012.xml")
assert not os.path.exists(debitor_filepath)
order_filepath = os.path.join(
tmppath, "order/myVBV_orde_60000012_20240215083312_6000000124.xml"
)
assert not os.path.exists(order_filepath)

View File

@ -2,3 +2,5 @@
addopts = --ds=config.settings.test --no-migrations addopts = --ds=config.settings.test --no-migrations
python_files = tests.py test_*.py python_files = tests.py test_*.py
norecursedirs = node_modules norecursedirs = node_modules
markers =
serial: marks tests as serial (not to be run in parallel)

View File

@ -10,6 +10,7 @@ django-stubs # https://github.com/typeddjango/django-stubs
pytest # https://github.com/pytest-dev/pytest pytest # https://github.com/pytest-dev/pytest
pytest-sugar # https://github.com/Frozenball/pytest-sugar pytest-sugar # https://github.com/Frozenball/pytest-sugar
pytest-xdist # pytest-xdist #
pytest-order
djangorestframework-stubs # https://github.com/typeddjango/djangorestframework-stubs djangorestframework-stubs # https://github.com/typeddjango/djangorestframework-stubs
@ -33,11 +34,11 @@ django-coverage-plugin # https://github.com/nedbat/django_coverage_plugin
pytest-django # https://github.com/pytest-dev/pytest-django pytest-django # https://github.com/pytest-dev/pytest-django
freezegun # https://github.com/spulec/freezegun freezegun # https://github.com/spulec/freezegun
# django-watchfiles custom PR
https://github.com/q0w/django-watchfiles/archive/issue-1.zip
# code checking # code checking
truffleHog truffleHog
# deployement and CI # deployement and CI
git+https://github.com/iterativ/Caprover-API.git@5013f8fc929e8e3281b9d609e968a782e8e99530 git+https://github.com/iterativ/Caprover-API.git@5013f8fc929e8e3281b9d609e968a782e8e99530
# sftpserver for tests
git+https://github.com/lonetwin/sftpserver.git@1d16896d3f0f90d63d1caaf4e199f2a9dde6456f

View File

@ -131,7 +131,6 @@ django==3.2.20
# django-stubs-ext # django-stubs-ext
# django-taggit # django-taggit
# django-treebeard # django-treebeard
# django-watchfiles
# djangorestframework # djangorestframework
# drf-spectacular # drf-spectacular
# graphene-django # graphene-django
@ -186,8 +185,6 @@ django-taggit==4.0.0
# via wagtail # via wagtail
django-treebeard==4.7 django-treebeard==4.7
# via wagtail # via wagtail
django-watchfiles @ https://github.com/q0w/django-watchfiles/archive/issue-1.zip
# via -r requirements-dev.in
djangorestframework==3.14.0 djangorestframework==3.14.0
# via # via
# -r requirements.in # -r requirements.in
@ -341,7 +338,9 @@ packaging==23.1
# pytest # pytest
# pytest-sugar # pytest-sugar
paramiko==3.3.1 paramiko==3.3.1
# via -r requirements.in # via
# -r requirements.in
# sftpserver
parso==0.8.3 parso==0.8.3
# via jedi # via jedi
pathspec==0.11.2 pathspec==0.11.2
@ -397,9 +396,7 @@ pyflakes==3.1.0
pygments==2.16.1 pygments==2.16.1
# via ipython # via ipython
pyjwt[crypto]==2.8.0 pyjwt[crypto]==2.8.0
# via # via msal
# msal
# pyjwt
pylint==2.17.5 pylint==2.17.5
# via # via
# pylint-django # pylint-django
@ -416,10 +413,13 @@ pytest==7.4.0
# via # via
# -r requirements-dev.in # -r requirements-dev.in
# pytest-django # pytest-django
# pytest-order
# pytest-sugar # pytest-sugar
# pytest-xdist # pytest-xdist
pytest-django==4.5.2 pytest-django==4.5.2
# via -r requirements-dev.in # via -r requirements-dev.in
pytest-order==1.2.1
# via -r requirements-dev.in
pytest-sugar==0.9.7 pytest-sugar==0.9.7
# via -r requirements-dev.in # via -r requirements-dev.in
pytest-xdist==3.5.0 pytest-xdist==3.5.0
@ -480,6 +480,8 @@ sendgrid==6.10.0
# via -r requirements.in # via -r requirements.in
sentry-sdk==1.29.2 sentry-sdk==1.29.2
# via -r requirements.in # via -r requirements.in
sftpserver @ git+https://github.com/lonetwin/sftpserver.git@1d16896d3f0f90d63d1caaf4e199f2a9dde6456f
# via -r requirements-dev.in
six==1.16.0 six==1.16.0
# via # via
# asttokens # asttokens
@ -607,9 +609,7 @@ wagtail-headless-preview==0.6.0
wagtail-localize==1.5.1 wagtail-localize==1.5.1
# via -r requirements.in # via -r requirements.in
watchfiles==0.19.0 watchfiles==0.19.0
# via # via uvicorn
# django-watchfiles
# uvicorn
wcwidth==0.2.6 wcwidth==0.2.6
# via prompt-toolkit # via prompt-toolkit
webencodings==0.5.1 webencodings==0.5.1
@ -621,9 +621,7 @@ wheel==0.41.1
whitenoise[brotli]==6.5.0 whitenoise[brotli]==6.5.0
# via -r requirements.in # via -r requirements.in
willow[heif]==1.6.1 willow[heif]==1.6.1
# via # via wagtail
# wagtail
# willow
wrapt==1.15.0 wrapt==1.15.0
# via astroid # via astroid

View File

@ -31,8 +31,7 @@ azure-core==1.29.1
azure-identity==1.14.0 azure-identity==1.14.0
# via -r requirements.in # via -r requirements.in
azure-storage-blob==12.17.0 azure-storage-blob==12.17.0
# via # via -r requirements.in
# -r requirements.in
bcrypt==4.0.1 bcrypt==4.0.1
# via paramiko # via paramiko
beautifulsoup4==4.11.2 beautifulsoup4==4.11.2

View File

@ -3,4 +3,4 @@
cd "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cd "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# limit test to 6 parallel processes, otherwise ratelimit of s3 could be hit # limit test to 6 parallel processes, otherwise ratelimit of s3 could be hit
pytest --numprocesses auto --maxprocesses=6 --junitxml=../test-reports/coverage.xml pytest --numprocesses auto --maxprocesses=6 --dist=loadscope --junitxml=../test-reports/coverage.xml $1

View File

@ -3,7 +3,7 @@
set -e set -e
cd "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cd "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
coverage run -m pytest --numprocesses auto --maxprocesses=6 --junitxml=../test-reports/coverage.xml $1 pytest --numprocesses auto --maxprocesses=6 --dist=loadscope --junitxml=../test-reports/coverage.xml $1
coverage_python=`coverage report -m | tail -n1 | awk '{print $4}'` coverage_python=`coverage report -m | tail -n1 | awk '{print $4}'`
commit=`git rev-parse HEAD` commit=`git rev-parse HEAD`

View File

@ -42,58 +42,32 @@ class EntitiesViewTest(APITestCase):
}, },
) )
countries = response.data["countries"]
self.assertEqual(
countries[0],
{
"id": 1,
"name": "Afghanistan",
},
)
def test_list_country_entities_ordered_by_country_id(self) -> None:
# GIVEN
url = reverse("list_entities")
first_country = Country.objects.get(country_id=1)
# WHEN
response = self.client.get(url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
countries = response.data["countries"]
self.assertEqual(
countries[0],
{
"id": first_country.country_id,
"name": first_country.name_de,
},
)
def test_list_country_entities_ordered_by_order_id(self) -> None: def test_list_country_entities_ordered_by_order_id(self) -> None:
# GIVEN
url = reverse("list_entities") url = reverse("list_entities")
switzerland = Country.objects.get(name_de="Schweiz")
switzerland.order_id = 1
switzerland.save()
# WHEN
response = self.client.get(url) response = self.client.get(url)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
countries = response.data["countries"] countries = response.data["countries"]
self.assertEqual( self.assertEqual(
countries[0], countries[0],
{ {
"id": switzerland.country_id, "country_code": "CH",
"name": switzerland.name_de, "vbv_country_id": 209,
"name": "Schweiz",
},
)
usa = Country.objects.get(country_code="US")
usa.order_id = 0.5
usa.save()
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
countries = response.data["countries"]
self.assertEqual(
countries[0],
{
"country_code": "US",
"vbv_country_id": usa.vbv_country_id,
"name": usa.name_de,
}, },
) )

View File

@ -13,7 +13,7 @@ class MeUserViewTest(APITestCase):
) )
self.client.login(username="testuser", password="testpassword") self.client.login(username="testuser", password="testpassword")
add_organisations() add_organisations()
add_countries() add_countries(small_set=True)
def test_user_can_update_language(self) -> None: def test_user_can_update_language(self) -> None:
# GIVEN # GIVEN

View File

@ -123,7 +123,8 @@ class OrganisationAdmin(admin.ModelAdmin):
class CountryAdmin(admin.ModelAdmin): class CountryAdmin(admin.ModelAdmin):
list_display = ( list_display = (
"order_id", "order_id",
"country_id", "country_code",
"vbv_country_id",
"name_de", "name_de",
"name_fr", "name_fr",
"name_it", "name_it",

View File

@ -27,6 +27,7 @@ TEST_STUDENT3_USER_ID = "bcf94dba-53bc-474b-a22d-e4af39aa042b"
TEST_MENTOR1_USER_ID = "d1f5f5a9-5b0a-4e1a-9e1a-9e9b5b5e1b1b" TEST_MENTOR1_USER_ID = "d1f5f5a9-5b0a-4e1a-9e1a-9e9b5b5e1b1b"
TEST_STUDENT1_VV_USER_ID = "5ff59857-8de5-415e-a387-4449f9a0337a" TEST_STUDENT1_VV_USER_ID = "5ff59857-8de5-415e-a387-4449f9a0337a"
TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID = "7e8ebf0b-e6e2-4022-88f4-6e663ba0a9db" TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID = "7e8ebf0b-e6e2-4022-88f4-6e663ba0a9db"
TEST_USER_EMPTY_ID = "daecbabe-4ab9-4edf-a71f-4119042ccb02"
TEST_COURSE_SESSION_BERN_ID = -1 TEST_COURSE_SESSION_BERN_ID = -1
TEST_COURSE_SESSION_ZURICH_ID = -2 TEST_COURSE_SESSION_ZURICH_ID = -2

View File

@ -20,6 +20,7 @@ from vbv_lernwelt.core.constants import (
TEST_SUPERVISOR1_USER_ID, TEST_SUPERVISOR1_USER_ID,
TEST_TRAINER1_USER_ID, TEST_TRAINER1_USER_ID,
TEST_TRAINER2_USER_ID, TEST_TRAINER2_USER_ID,
TEST_USER_EMPTY_ID,
) )
from vbv_lernwelt.core.models import User from vbv_lernwelt.core.models import User
@ -192,6 +193,15 @@ def create_default_users(default_password="test", set_avatar=False):
password=env("IT_DEFAULT_ADMIN_PASSWORD", default_password), password=env("IT_DEFAULT_ADMIN_PASSWORD", default_password),
) )
_create_user(
TEST_USER_EMPTY_ID,
"empty@example.com",
"Flasche",
"Leer",
password=default_password,
language="de",
)
for user_data in default_users: for user_data in default_users:
_create_student_user(**user_data) _create_student_user(**user_data)

View File

@ -1,6 +1,7 @@
from datetime import datetime from datetime import datetime
import djclick as click import djclick as click
from django.contrib.auth.hashers import make_password
from django.utils import timezone from django.utils import timezone
from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion
@ -14,6 +15,7 @@ from vbv_lernwelt.core.constants import (
TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID, TEST_STUDENT2_VV_AND_VV_MENTOR_USER_ID,
TEST_STUDENT3_USER_ID, TEST_STUDENT3_USER_ID,
TEST_TRAINER1_USER_ID, TEST_TRAINER1_USER_ID,
TEST_USER_EMPTY_ID,
) )
from vbv_lernwelt.core.models import Organisation, User from vbv_lernwelt.core.models import Organisation, User
from vbv_lernwelt.course.consts import ( from vbv_lernwelt.course.consts import (
@ -48,6 +50,7 @@ from vbv_lernwelt.self_evaluation_feedback.models import (
CourseCompletionFeedback, CourseCompletionFeedback,
SelfEvaluationFeedback, SelfEvaluationFeedback,
) )
from vbv_lernwelt.shop.models import CheckoutInformation
@click.command() @click.command()
@ -142,6 +145,18 @@ def command(
User.objects.all().update(language="de") User.objects.all().update(language="de")
User.objects.all().update(additional_json_data={}) User.objects.all().update(additional_json_data={})
CheckoutInformation.objects.filter(user_id=TEST_USER_EMPTY_ID).delete()
User.objects.filter(id=TEST_USER_EMPTY_ID).delete()
user, _ = User.objects.get_or_create(
id=TEST_USER_EMPTY_ID,
username="empty@example.com",
email="empty@example.com",
language="de",
first_name="Flasche",
last_name="Leer",
password=make_password("test"),
)
if create_assignment_completion or create_assignment_evaluation: if create_assignment_completion or create_assignment_evaluation:
print("create assignment completion data for test course") print("create assignment completion data for test course")
create_test_assignment_submitted_data( create_test_assignment_submitted_data(

View File

@ -2,6 +2,15 @@
from django.db import migrations, models from django.db import migrations, models
from vbv_lernwelt.core.model_utils import countries
def populate_country_order_id(apps, schema_editor):
Country = apps.get_model("core", "Country")
for country in Country.objects.all():
country.order_id = countries[country.country_id].get("order_id", 20.0)
country.save(update_fields=["order_id"])
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@ -22,4 +31,5 @@ class Migration(migrations.Migration):
name="order_id", name="order_id",
field=models.FloatField(default=20), field=models.FloatField(default=20),
), ),
migrations.RunPython(populate_country_order_id),
] ]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.20 on 2024-05-29 13:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0007_auto_20240220_1058"),
]
operations = [
migrations.AddField(
model_name="user",
name="abacus_debitor_number",
field=models.BigIntegerField(blank=True, null=True, unique=True),
),
]

View File

@ -0,0 +1,112 @@
# Generated by Django 3.2.20 on 2024-05-30 10:34
from django.db import migrations, models
from vbv_lernwelt.core.model_utils import countries
def populate_country_code(apps, schema_editor):
Country = apps.get_model("core", "Country")
for country in Country.objects.all():
country.country_code = countries[country.country_id]["country_code"]
country.save(update_fields=["country_code"])
def migrate_user_country(apps, schema_editor):
User = apps.get_model("core", "User")
Country = apps.get_model("core", "Country")
for user in User.objects.all():
if user.old_country:
country = Country.objects.get(vbv_country_id=user.old_country)
user.country = country
if user.old_organisation_country:
country = Country.objects.get(vbv_country_id=user.old_organisation_country)
user.organisation_country = country
user.save(update_fields=["country", "organisation_country"])
class Migration(migrations.Migration):
dependencies = [
("core", "0008_user_abacus_debitor_number"),
]
operations = [
migrations.AlterField(
model_name="user",
name="country",
field=models.IntegerField(null=True, blank=True),
),
migrations.AlterField(
model_name="user",
name="organisation_country",
field=models.IntegerField(null=True, blank=True),
),
migrations.AddField(
model_name="country",
name="country_code",
field=models.CharField(max_length=2, null=True),
),
migrations.RunPython(populate_country_code),
migrations.AlterField(
model_name="country",
name="country_id",
field=models.IntegerField(),
),
migrations.RenameField(
model_name="country", old_name="country_id", new_name="vbv_country_id"
),
migrations.AlterField(
model_name="country",
name="country_code",
field=models.CharField(max_length=2, primary_key=True, serialize=False),
),
migrations.RenameField(
model_name="user",
old_name="country",
new_name="old_country",
),
migrations.RenameField(
model_name="user",
old_name="organisation_country",
new_name="old_organisation_country",
),
migrations.AddField(
model_name="user",
name="country",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.SET_NULL,
related_name="+",
to="core.country",
),
),
migrations.AddField(
model_name="user",
name="organisation_country",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.SET_NULL,
related_name="+",
to="core.country",
),
),
migrations.RunPython(migrate_user_country),
migrations.RemoveField(
model_name="user",
name="old_country",
),
migrations.RemoveField(
model_name="user",
name="old_organisation_country",
),
migrations.AlterModelOptions(
name="country",
options={
"ordering": ["order_id", "vbv_country_id"],
"verbose_name": "Country",
"verbose_name_plural": "Countries",
},
),
]

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ import uuid
import structlog import structlog
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.db.models import JSONField from django.db.models import JSONField, Max
from django.urls import reverse from django.urls import reverse
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@ -25,19 +25,20 @@ class Organisation(models.Model):
class Country(models.Model): class Country(models.Model):
country_id = models.IntegerField(primary_key=True) country_code = models.CharField(max_length=2, primary_key=True)
vbv_country_id = models.IntegerField(primary_key=False)
name_de = models.CharField(max_length=255) name_de = models.CharField(max_length=255)
name_fr = models.CharField(max_length=255) name_fr = models.CharField(max_length=255)
name_it = models.CharField(max_length=255) name_it = models.CharField(max_length=255)
order_id = models.FloatField(default=20) order_id = models.FloatField(default=20)
def __str__(self): def __str__(self):
return f"{self.name_de} ({self.country_id})" return f"{self.name_de} ({self.country_code}) ({self.vbv_country_id})"
class Meta: class Meta:
verbose_name = "Country" verbose_name = "Country"
verbose_name_plural = "Countries" verbose_name_plural = "Countries"
ordering = ["order_id", "country_id"] ordering = ["order_id", "vbv_country_id"]
class User(AbstractUser): class User(AbstractUser):
@ -91,7 +92,7 @@ class User(AbstractUser):
city = models.CharField(max_length=255, blank=True) city = models.CharField(max_length=255, blank=True)
country = models.ForeignKey( country = models.ForeignKey(
Country, Country,
related_name="user_country", related_name="+",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
@ -104,12 +105,30 @@ class User(AbstractUser):
organisation_city = models.CharField(max_length=255, blank=True) organisation_city = models.CharField(max_length=255, blank=True)
organisation_country = models.ForeignKey( organisation_country = models.ForeignKey(
Country, Country,
related_name="organisation_country", related_name="+",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
) )
# is only set by abacus invoice export code
abacus_debitor_number = models.BigIntegerField(unique=True, null=True, blank=True)
def set_increment_abacus_debitor_number(self):
if self.abacus_debitor_number:
return self
# Get the current maximum debitor_number and increment it by 1
current_max = User.objects.aggregate(max_number=Max("abacus_debitor_number"))[
"max_number"
]
new_debitor_number = (
current_max if current_max is not None else 60_000_000
) + 1
self.abacus_debitor_number = new_debitor_number
self.save()
return self
def create_avatar_url(self, size=400): def create_avatar_url(self, size=400):
try: try:
if self.avatar: if self.avatar:

View File

@ -14,12 +14,11 @@ def create_json_from_objects(objects, serializer_class, many=True) -> str:
class CountrySerializer(serializers.ModelSerializer): class CountrySerializer(serializers.ModelSerializer):
id = serializers.IntegerField(source="country_id", read_only=True)
name = serializers.SerializerMethodField() name = serializers.SerializerMethodField()
class Meta: class Meta:
model = Country model = Country
fields = ["id", "name"] fields = ["country_code", "vbv_country_id", "name"]
def get_name(self, obj): def get_name(self, obj):
language = self.context.get("langauge") language = self.context.get("langauge")
@ -32,11 +31,15 @@ class CountrySerializer(serializers.ModelSerializer):
return obj.name_de return obj.name_de
def to_internal_value(self, data): def to_internal_value(self, data):
country_id = data.get("id") country_code = data.get("country_code")
if country_id is not None: if country_code is not None:
try: try:
country = Country.objects.get(country_id=country_id) country = Country.objects.get(country_code=country_code)
return {"id": country.country_id, "name": self.get_name(country)} return {
"country_code": country.country_code,
"vbv_country_id": country.vbv_country_id,
"name": self.get_name(country),
}
except Country.DoesNotExist: except Country.DoesNotExist:
raise serializers.ValidationError({"id": "Invalid country ID"}) raise serializers.ValidationError({"id": "Invalid country ID"})
return super().to_internal_value(data) return super().to_internal_value(data)
@ -105,14 +108,14 @@ class UserSerializer(serializers.ModelSerializer):
setattr(instance, attr, value) setattr(instance, attr, value)
if country_data is not None: if country_data is not None:
country_id = country_data.get("id") country_code = country_data.get("country_code")
country_instance = Country.objects.filter(country_id=country_id).first() country_instance = Country.objects.filter(country_code=country_code).first()
instance.country = country_instance instance.country = country_instance
if organisation_country_data is not None: if organisation_country_data is not None:
organisation_country_id = organisation_country_data.get("id") organisation_country_code = organisation_country_data.get("country_code")
organisation_country_instance = Country.objects.filter( organisation_country_instance = Country.objects.filter(
country_id=organisation_country_id country_code=organisation_country_code
).first() ).first()
instance.organisation_country = organisation_country_instance instance.organisation_country = organisation_country_instance

View File

@ -0,0 +1,30 @@
from django.test import TestCase
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.models import User
class UserAbacusDebitorNumberTestCase(TestCase):
def setUp(self):
create_default_users()
def test_set_debitor_number(self):
pat = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")
self.assertIsNone(pat.abacus_debitor_number)
pat.set_increment_abacus_debitor_number()
self.assertEqual(pat.abacus_debitor_number, 60000001)
pat = pat.set_increment_abacus_debitor_number()
self.assertEqual(pat.abacus_debitor_number, 60000001)
pat = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")
self.assertEqual(pat.abacus_debitor_number, 60000001)
feuz = User.objects.get(username="andreas.feuz@eiger-versicherungen.ch")
self.assertIsNone(feuz.abacus_debitor_number)
feuz = feuz.set_increment_abacus_debitor_number()
self.assertEqual(feuz.abacus_debitor_number, 60000002)
feuz = User.objects.get(username="andreas.feuz@eiger-versicherungen.ch")
self.assertEqual(feuz.abacus_debitor_number, 60000002)

View File

@ -74,3 +74,8 @@ After everything runs fine, we should be able to remove the following deprecated
8. `IT_OAUTH_SCOPE` 8. `IT_OAUTH_SCOPE`
### Datatrans Test Credit Card
5100 0010 0000 0014
06/25
123

View File

@ -1,14 +1,10 @@
from django.contrib import admin from django.contrib import admin
from vbv_lernwelt.shop.invoice.abacus import abacus_ssh_upload
from vbv_lernwelt.shop.models import CheckoutInformation, Product from vbv_lernwelt.shop.models import CheckoutInformation, Product
from vbv_lernwelt.shop.services import get_transaction_state 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") @admin.action(description="DATATRANS: Sync transaction states")
def sync_transaction_state(modeladmin, request, queryset): def sync_transaction_state(modeladmin, request, queryset):
for checkout in queryset: for checkout in queryset:
@ -23,18 +19,60 @@ def sync_transaction_state(modeladmin, request, queryset):
@admin.register(CheckoutInformation) @admin.register(CheckoutInformation)
class CheckoutInformationAdmin(admin.ModelAdmin): class CheckoutInformationAdmin(admin.ModelAdmin):
@admin.action(description="ABACUS: Upload invoice to SFTP server")
def abacus_upload_order(self, request, queryset):
success = True
for ci in queryset:
if not abacus_ssh_upload(ci):
success = False
if not success:
self.message_user(
request, f"Beim SFTP upload ist ein Fehler aufgetreten", level="error"
)
@staticmethod
def customer(obj):
return f"{obj.user.first_name} {obj.user.last_name} ({obj.user.email})"
@staticmethod
def debitor_number(obj):
return obj.user.abacus_debitor_number
def has_delete_permission(self, request, obj=None):
# Disable delete
return False
list_display = ( list_display = (
"product_sku", "product_sku",
"user", customer,
"product_name", "product_name",
"product_price", "product_price",
"updated_at", "created_at",
"state", "state",
"invoice_transmitted_at", "invoice_transmitted_at",
"abacus_order_id",
debitor_number,
"abacus_ssh_upload_done",
) )
search_fields = ["user__email"] search_fields = [
list_filter = ("state", "product_name") "user__email",
actions = [generate_invoice, sync_transaction_state] "user__first_name",
"user__last_name",
"user__username",
"transaction_id",
"abacus_order_id",
"user__abacus_debitor_number",
]
list_filter = ("state", "product_name", "product_sku", "abacus_ssh_upload_done")
date_hierarchy = "created_at"
actions = [abacus_upload_order, sync_transaction_state]
readonly_fields = [
"user",
"transaction_id",
"state",
"product_price",
"webhook_history",
]
@admin.register(Product) @admin.register(Product)

View File

@ -0,0 +1,142 @@
import hashlib
import hmac
import json
import threading
import time
import requests
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.shortcuts import redirect
from django.views.decorators.csrf import csrf_exempt
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
from vbv_lernwelt.core.models import User
@csrf_exempt
@django_view_authentication_exempt
def fake_datatrans_api_view(request, api_url=""):
if api_url == "/v1/transactions" and request.method == "POST":
data = json.loads(request.body.decode("utf-8"))
user = User.objects.get(id=data["user_id"])
user.additional_json_data["datatrans_transaction_payload"] = data
user.save()
return JsonResponse({"transactionId": data["refno"]}, status=201)
return HttpResponse(
content="unknown api url", content_type="application/json", status=400
)
@csrf_exempt
@django_view_authentication_exempt
def fake_datatrans_pay_view(request, api_url=""):
def call_transaction_complete_webhook(
webhook_url, transaction_id, datatrans_status="settled"
):
time.sleep(1)
payload = {
"transactionId": transaction_id,
"status": datatrans_status,
}
key_hex_bytes = bytes.fromhex(settings.DATATRANS_HMAC_KEY)
# Create sign with timestamp and payload
sign = hmac.new(
key_hex_bytes, bytes(str(1) + json.dumps(payload), "utf-8"), hashlib.sha256
)
response = requests.post(
url=webhook_url,
json=payload,
headers={"Datatrans-Signature": f"t=1,s0={sign.hexdigest()}"},
)
print(response)
if api_url.startswith("/v1/start/"):
transaction_id = api_url.split("/")[-1]
transaction_user = User.objects.filter(
additional_json_data__datatrans_transaction_payload__refno=transaction_id
).first()
if transaction_user is None:
return HttpResponse(
content=f"""
<h1>Fake Datatrans Payment</h1>
<p>No active transaction found for {transaction_id}</p>
""",
status=404,
)
if request.method == "GET":
return HttpResponse(
content=f"""
<h1>Fake Datatrans Payment</h1>
<form action="{request.build_absolute_uri()}" method="post">
<fieldset>
<legend>Datatrans payment result status</legend>
<div>
<input type="radio" name="payment" value="settled" checked/>
<label>settled</label>
<input type="radio" name="payment" value="cancelled" />
<label>cancelled</label>
<input type="radio" name="payment" value="failed" />
<label>failed</label>
</div>
<div>
<button type="submit">
Pay with selected Status
</button>
</div>
</fieldset>
</form>
"""
)
elif request.method == "POST":
payment_status = request.POST.get("payment", "settled")
if payment_status == "settled":
success_url = transaction_user.additional_json_data[
"datatrans_transaction_payload"
]["redirect"]["successUrl"]
# start new thread which will call webhook after 2 seconds
webhook_url = transaction_user.additional_json_data[
"datatrans_transaction_payload"
]["webhook"]["url"]
thread = threading.Thread(
target=call_transaction_complete_webhook,
args=(
webhook_url,
transaction_id,
),
)
thread.start()
# redirect to url
return redirect(success_url + f"?datatransTrxId={transaction_id}")
if payment_status == "cancelled":
cancel_url = transaction_user.additional_json_data[
"datatrans_transaction_payload"
]["redirect"]["cancelUrl"]
# redirect to url
return redirect(cancel_url + f"?datatransTrxId={transaction_id}")
if payment_status == "failed":
error_url = transaction_user.additional_json_data[
"datatrans_transaction_payload"
]["redirect"]["errorUrl"]
# redirect to url
return redirect(error_url + f"?datatransTrxId={transaction_id}")
return HttpResponse(
content="unknown api url", content_type="application/json", status=400
)

View File

@ -1,57 +1,124 @@
import datetime import datetime
from typing import List from io import BytesIO
from uuid import uuid4
from xml.dom import minidom from xml.dom import minidom
from xml.etree.ElementTree import Element, SubElement, tostring from xml.etree.ElementTree import Element, SubElement, tostring
from vbv_lernwelt.shop.invoice.creator import InvoiceCreator, Item import structlog
from vbv_lernwelt.shop.invoice.repositories import InvoiceRepository
from vbv_lernwelt.shop.models import CheckoutInformation from vbv_lernwelt.shop.invoice.abacus_sftp_client import AbacusSftpClient
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState
logger = structlog.get_logger(__name__)
class AbacusInvoiceCreator(InvoiceCreator): def abacus_ssh_upload(checkout_information: CheckoutInformation):
def __init__(self, repository: InvoiceRepository): if checkout_information.state != CheckoutState.PAID:
self.repository = repository # only upload invoice if checkout is paid
return True
def create_invoice( try:
self, if not checkout_information.abacus_ssh_upload_done:
checkout_information: CheckoutInformation, # only upload data for not yet uploaded invoices
filename: str = None, invoice_xml_filename, invoice_xml_content = create_invoice_xml(
): checkout_information
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,
) )
] customer_xml_filename, customer_xml_content = create_customer_xml(
checkout_information
invoice = self.invoice_xml(
customer_number,
order_date,
reference_purchase_order,
unic_id,
items,
) )
if filename is None: abacus_ssh_upload_invoice(
filename = f"vbv-vv-{uuid4().hex}.xml" customer_xml_filename, customer_xml_content, folder="debitor"
)
abacus_ssh_upload_invoice(
invoice_xml_filename, invoice_xml_content, folder="order"
)
self.repository.upload_invoice(invoice, filename) checkout_information.abacus_ssh_upload_done = True
checkout_information.invoice_transmitted_at = datetime.datetime.now()
checkout_information.save()
return True
@staticmethod except Exception as e:
def invoice_xml( logger.warning(
customer_number: str, "Error uploading invoice to Abacus SFTP",
checkout_information_id=checkout_information.id,
exception=str(e),
exc_info=True,
)
return False
def create_invoice_xml(checkout_information: CheckoutInformation):
# set fill abacus numbers
checkout_information = checkout_information.set_increment_abacus_order_id()
customer = checkout_information.user.set_increment_abacus_debitor_number()
invoice_xml_content = render_invoice_xml(
abacus_debitor_number=customer.abacus_debitor_number,
abacus_order_id=checkout_information.abacus_order_id,
datatrans_transaction_id=checkout_information.transaction_id,
order_date=checkout_information.created_at.date(),
item_description=f"{checkout_information.product_name}, {checkout_information.created_at.date().isoformat()}, {checkout_information.user.last_name} {checkout_information.user.first_name}",
)
# YYYYMMDDhhmmss
filename_datetime = checkout_information.created_at.strftime("%Y%m%d%H%M%S")
invoice_xml_filename = f"myVBV_orde_{customer.abacus_debitor_number}_{filename_datetime}_{checkout_information.abacus_order_id}.xml"
return invoice_xml_filename, invoice_xml_content
def create_customer_xml(checkout_information: CheckoutInformation):
customer = checkout_information.user.set_increment_abacus_debitor_number()
customer_xml_content = render_customer_xml(
abacus_debitor_number=customer.abacus_debitor_number,
last_name=checkout_information.last_name,
first_name=checkout_information.first_name,
company_name=checkout_information.organisation_detail_name
if checkout_information.invoice_address == "org"
else "",
street=(
checkout_information.organisation_street
if checkout_information.invoice_address == "org"
else checkout_information.street
),
house_number=(
checkout_information.organisation_street_number
if checkout_information.invoice_address == "org"
else checkout_information.street_number
),
zip_code=(
checkout_information.organisation_postal_code
if checkout_information.invoice_address == "org"
else checkout_information.postal_code
),
city=(
checkout_information.organisation_city
if checkout_information.invoice_address == "org"
else checkout_information.city
),
country=(
checkout_information.organisation_country_id
if checkout_information.invoice_address == "org"
else checkout_information.country_id
),
language=customer.language,
email=customer.email,
)
customer_xml_filename = f"myVBV_debi_{customer.abacus_debitor_number}.xml"
return customer_xml_filename, customer_xml_content
def render_invoice_xml(
abacus_debitor_number: int,
abacus_order_id: int,
datatrans_transaction_id: str,
order_date: datetime.date, order_date: datetime.date,
reference_purchase_order: str, item_description: str,
unic_id: str, ) -> str:
items: List[Item],
) -> str:
container = Element("AbaConnectContainer") container = Element("AbaConnectContainer")
task = SubElement(container, "Task") task = SubElement(container, "Task")
parameter = SubElement(task, "Parameter") parameter = SubElement(task, "Parameter")
@ -66,37 +133,51 @@ class AbacusInvoiceCreator(InvoiceCreator):
sales_order_header, "SalesOrderHeaderFields", mode="SAVE" sales_order_header, "SalesOrderHeaderFields", mode="SAVE"
) )
SubElement(sales_order_header_fields, "CustomerNumber").text = customer_number # Skender: Kundennummer, erste Ziffer abhängig von der Plattform (4 = LMS, 6 = myVBV, 7 = EduManager), Plattform führt ein eigenständiges hochzählendes Mapping.
SubElement(sales_order_header_fields, "CustomerNumber").text = str(
abacus_debitor_number
)
# Skender: ePayment: Ablaufnr. für ePayment Rechnung in Abacus
SubElement(sales_order_header_fields, "ProcessFlowNumber").text = "30"
# Skender: ePayment: Zahlungskondition für ePaymente in Abacus 9999 Tage Mahnungsfrist, da schon bezahlt
SubElement(sales_order_header_fields, "PaymentCode").text = "9999"
# Skender: Bestellzeitpunkt
SubElement( SubElement(
sales_order_header_fields, "PurchaseOrderDate" sales_order_header_fields, "PurchaseOrderDate"
).text = order_date.isoformat() ).text = order_date.isoformat()
SubElement(
sales_order_header_fields, "DeliveryDate" # Skender: ePayment: TRANSACTION-ID von Datatrans in Bestellreferenz
).text = order_date.isoformat()
SubElement( SubElement(
sales_order_header_fields, "ReferencePurchaseOrder" sales_order_header_fields, "ReferencePurchaseOrder"
).text = reference_purchase_order ).text = datatrans_transaction_id
SubElement(sales_order_header_fields, "UnicId").text = unic_id
# Skender: ePayment: OrderID. max 10 Ziffern, erste Ziffer abhängig von der Plattform (4 = LMS, 6 = myVBV, 7 = EduManager)
SubElement(sales_order_header_fields, "GroupingNumberAscii1").text = str(
abacus_order_id
)
for index, item in enumerate(items, start=1):
item_element = SubElement(sales_order_header, "Item", mode="SAVE") item_element = SubElement(sales_order_header, "Item", mode="SAVE")
item_fields = SubElement(item_element, "ItemFields", mode="SAVE") item_fields = SubElement(item_element, "ItemFields", mode="SAVE")
SubElement(item_fields, "ItemNumber").text = str(index) SubElement(item_fields, "DeliveryDate").text = order_date.isoformat()
SubElement(item_fields, "ProductNumber").text = item.product_number SubElement(item_fields, "ItemNumber").text = "1"
SubElement(item_fields, "QuantityOrdered").text = str(item.quantity) SubElement(item_fields, "ProductNumber").text = "30202"
SubElement(item_fields, "QuantityOrdered").text = "1"
item_text = SubElement(item_element, "ItemText", mode="SAVE") item_text = SubElement(item_element, "ItemText", mode="SAVE")
item_text_fields = SubElement(item_text, "ItemTextFields", mode="SAVE") item_text_fields = SubElement(item_text, "ItemTextFields", mode="SAVE")
SubElement(item_text_fields, "Text").text = item.description SubElement(item_text_fields, "Text").text = item_description
return AbacusInvoiceCreator.create_xml_string(container) return create_xml_string(container)
@staticmethod
def customer_xml( def render_customer_xml(
customer_number: str, abacus_debitor_number: int,
name: str, last_name: str,
first_name: str, first_name: str,
address_text: str, company_name: str,
street: str, street: str,
house_number: str, house_number: str,
zip_code: str, zip_code: str,
@ -104,7 +185,7 @@ class AbacusInvoiceCreator(InvoiceCreator):
country: str, country: str,
language: str, language: str,
email: str, email: str,
): ):
container = Element("AbaConnectContainer") container = Element("AbaConnectContainer")
task = SubElement(container, "Task") task = SubElement(container, "Task")
@ -117,16 +198,17 @@ class AbacusInvoiceCreator(InvoiceCreator):
transaction = SubElement(task, "Transaction") transaction = SubElement(task, "Transaction")
customer_element = SubElement(transaction, "Customer", mode="SAVE") customer_element = SubElement(transaction, "Customer", mode="SAVE")
SubElement(customer_element, "CustomerNumber").text = customer_number SubElement(customer_element, "CustomerNumber").text = str(abacus_debitor_number)
SubElement(customer_element, "DefaultCurrency").text = "CHF" SubElement(customer_element, "DefaultCurrency").text = "CHF"
SubElement(customer_element, "PaymentTermNumber").text = "1" SubElement(customer_element, "PaymentTermNumber").text = "1"
SubElement(customer_element, "ReminderProcedure").text = "NORM" SubElement(customer_element, "ReminderProcedure").text = "NORM"
address_data = SubElement(customer_element, "AddressData", mode="SAVE") address_data = SubElement(customer_element, "AddressData", mode="SAVE")
SubElement(address_data, "AddressNumber").text = customer_number SubElement(address_data, "AddressNumber").text = str(abacus_debitor_number)
SubElement(address_data, "Name").text = name SubElement(address_data, "Name").text = last_name
SubElement(address_data, "FirstName").text = first_name SubElement(address_data, "FirstName").text = first_name
SubElement(address_data, "Text").text = address_text if company_name:
SubElement(address_data, "Text").text = company_name
SubElement(address_data, "Street").text = street SubElement(address_data, "Street").text = street
SubElement(address_data, "HouseNumber").text = house_number SubElement(address_data, "HouseNumber").text = house_number
SubElement(address_data, "ZIP").text = zip_code SubElement(address_data, "ZIP").text = zip_code
@ -135,12 +217,24 @@ class AbacusInvoiceCreator(InvoiceCreator):
SubElement(address_data, "Language").text = language SubElement(address_data, "Language").text = language
SubElement(address_data, "Email").text = email SubElement(address_data, "Email").text = email
return AbacusInvoiceCreator.create_xml_string(container) return create_xml_string(container)
@staticmethod
def create_xml_string(container: Element, encoding: str = "UTF-8") -> str: def create_xml_string(container: Element, encoding: str = "utf-8") -> str:
xml_bytes = tostring(container, encoding) xml_bytes = tostring(container, encoding)
xml_pretty_str = minidom.parseString(xml_bytes).toprettyxml( xml_pretty_str = minidom.parseString(xml_bytes).toprettyxml(
indent=" ", encoding=encoding indent=" ", encoding=encoding
) )
return xml_pretty_str.decode(encoding) return xml_pretty_str.decode(encoding)
def abacus_ssh_upload_invoice(
filename: str, content_xml: str, folder: str = ""
) -> None:
invoice_io = BytesIO(content_xml.encode("utf-8"))
with AbacusSftpClient() as sftp_client:
path = filename
if folder:
path = f"{folder}/{filename}"
sftp_client.putfo(invoice_io, path)

View File

@ -0,0 +1,28 @@
from django.conf import settings
from paramiko.client import AutoAddPolicy, SSHClient
def _create_abacus_sftp_client():
ssh_client = SSHClient()
ssh_client.set_missing_host_key_policy(AutoAddPolicy())
ssh_client.connect(
hostname=settings.ABACUS_EXPORT_SFTP_HOST,
port=settings.ABACUS_EXPORT_SFTP_PORT,
username=settings.ABACUS_EXPORT_SFTP_USERNAME,
password=settings.ABACUS_EXPORT_SFTP_PASSWORD,
look_for_keys=False,
allow_agent=False,
)
return ssh_client.open_sftp(), ssh_client
class AbacusSftpClient:
def __enter__(self):
(self.sftp_client, self.ssh_client) = _create_abacus_sftp_client()
return self.sftp_client
def __exit__(self, exc_type, exc_value, exc_traceback):
# self.sftp_client.close()
self.ssh_client.close()

View File

@ -1,21 +0,0 @@
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

@ -1,44 +0,0 @@
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

@ -3,6 +3,16 @@
from django.db import migrations, models from django.db import migrations, models
def add_default_shop_product(apps, schema_editor):
Product = apps.get_model("shop", "Product")
Product.objects.create(
sku="vv-de",
name="Versicherungsvermittler/-in VBV",
description="Versicherungsvermittler/-in VBV DE",
price=324_00,
)
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("shop", "0008_auto_20231117_0905"), ("shop", "0008_auto_20231117_0905"),
@ -16,4 +26,5 @@ class Migration(migrations.Migration):
help_text="The total price of the product in centimes -> 1000 = 10.00 CHF" help_text="The total price of the product in centimes -> 1000 = 10.00 CHF"
), ),
), ),
migrations.RunPython(add_default_shop_product),
] ]

View File

@ -0,0 +1,110 @@
# Generated by Django 3.2.20 on 2024-05-29 13:34
from django.db import migrations, models
def migrate_checkout_information_country(apps, schema_editor):
CheckoutInformation = apps.get_model("shop", "CheckoutInformation")
Country = apps.get_model("core", "Country")
for info in CheckoutInformation.objects.all():
if info.old_country:
country = Country.objects.get(vbv_country_id=info.old_country)
info.country = country
if info.old_company_country:
country = Country.objects.get(vbv_country_id=info.old_company_country)
info.organisation_country = country
info.invoice_address = "org"
info.save(update_fields=["country", "organisation_country", "invoice_address"])
class Migration(migrations.Migration):
dependencies = [
("shop", "0012_delete_country"),
("core", "0009_country_refactor"),
]
operations = [
migrations.AddField(
model_name="checkoutinformation",
name="abacus_order_id",
field=models.BigIntegerField(blank=True, null=True, unique=True),
),
migrations.RenameField(
model_name="checkoutinformation",
old_name="company_name",
new_name="organisation_detail_name",
),
migrations.RenameField(
model_name="checkoutinformation",
old_name="company_street",
new_name="organisation_street",
),
migrations.RenameField(
model_name="checkoutinformation",
old_name="company_street_number",
new_name="organisation_street_number",
),
migrations.RenameField(
model_name="checkoutinformation",
old_name="company_postal_code",
new_name="organisation_postal_code",
),
migrations.RenameField(
model_name="checkoutinformation",
old_name="company_city",
new_name="organisation_city",
),
migrations.RenameField(
model_name="checkoutinformation",
old_name="country",
new_name="old_country",
),
migrations.RenameField(
model_name="checkoutinformation",
old_name="company_country",
new_name="old_company_country",
),
migrations.AddField(
model_name="checkoutinformation",
name="invoice_address",
field=models.CharField(
choices=[("prv", "Private"), ("org", "Organisation")],
default="prv",
max_length=3,
),
),
migrations.AddField(
model_name="checkoutinformation",
name="country",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.SET_NULL,
related_name="+",
to="core.country",
),
),
migrations.AddField(
model_name="checkoutinformation",
name="organisation_country",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.SET_NULL,
related_name="+",
to="core.country",
),
),
migrations.RunPython(migrate_checkout_information_country),
migrations.RemoveField(
model_name="checkoutinformation",
name="old_country",
),
migrations.RemoveField(
model_name="checkoutinformation",
name="old_company_country",
),
migrations.DeleteModel(
name="BillingAddress",
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.20 on 2024-05-31 15:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("shop", "0013_checkoutinformation_abacus_order_id"),
]
operations = [
migrations.AddField(
model_name="checkoutinformation",
name="abacus_ssh_upload_done",
field=models.BooleanField(default=False),
),
]

View File

@ -1,33 +1,7 @@
from django.db import models from django.db import models
from django.db.models import Max
from vbv_lernwelt.core.models import Country
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): class Product(models.Model):
@ -61,6 +35,14 @@ class CheckoutState(models.TextChoices):
class CheckoutInformation(models.Model): class CheckoutInformation(models.Model):
INVOICE_ADDRESS_PRIVATE = "prv"
INVOICE_ADDRESS_ORGANISATION = "org"
INVOICE_ADDRESS_CHOICES = (
(INVOICE_ADDRESS_PRIVATE, "Private"),
(INVOICE_ADDRESS_ORGANISATION, "Organisation"),
)
user = models.ForeignKey("core.User", on_delete=models.PROTECT) user = models.ForeignKey("core.User", on_delete=models.PROTECT)
product_sku = models.CharField(max_length=255) product_sku = models.CharField(max_length=255)
@ -88,15 +70,50 @@ class CheckoutInformation(models.Model):
street_number = models.CharField(max_length=255) street_number = models.CharField(max_length=255)
postal_code = models.CharField(max_length=255) postal_code = models.CharField(max_length=255)
city = models.CharField(max_length=255) city = models.CharField(max_length=255)
country = models.CharField(max_length=255) country = models.ForeignKey(
Country,
related_name="+",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
# company (optional) invoice_address = models.CharField(
company_name = models.CharField(max_length=255, blank=True) max_length=3, choices=INVOICE_ADDRESS_CHOICES, default="prv"
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) # organisation data (optional)
company_city = models.CharField(max_length=255, blank=True) organisation_detail_name = models.CharField(max_length=255, blank=True)
company_country = models.CharField(max_length=255, blank=True) organisation_street = models.CharField(max_length=255, blank=True)
organisation_street_number = models.CharField(max_length=255, blank=True)
organisation_postal_code = models.CharField(max_length=255, blank=True)
organisation_city = models.CharField(max_length=255, blank=True)
organisation_country = models.ForeignKey(
Country,
related_name="+",
on_delete=models.SET_NULL,
null=True,
blank=True,
)
# webhook metadata # webhook metadata
webhook_history = models.JSONField(default=list) webhook_history = models.JSONField(default=list)
# is only set by abacus invoice export code
abacus_order_id = models.BigIntegerField(unique=True, null=True, blank=True)
abacus_ssh_upload_done = models.BooleanField(default=False)
def set_increment_abacus_order_id(self):
if self.abacus_order_id:
return self
# Get the current maximum abacus_order_id and increment it by 1
current_max = CheckoutInformation.objects.aggregate(
max_number=Max("abacus_order_id")
)["max_number"]
new_abacus_order_id = (
current_max if current_max is not None else 6_000_000_000
) + 1
self.abacus_order_id = new_abacus_order_id
self.save()
return self

View File

@ -1,23 +1 @@
from rest_framework import serializers
from .models import BillingAddress
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",
]

View File

@ -45,7 +45,7 @@ def is_signature_valid(
return s0_actual == s0_expected return s0_actual == s0_expected
def init_transaction( def init_datatrans_transaction(
user: User, user: User,
amount_chf_centimes: int, amount_chf_centimes: int,
redirect_url_success: str, redirect_url_success: str,
@ -53,13 +53,6 @@ def init_transaction(
redirect_url_cancel: str, redirect_url_cancel: str,
webhook_url: 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 = { payload = {
# We use autoSettle=True, so that we don't have to settle the transaction: # We use autoSettle=True, so that we don't have to settle the transaction:
# -> Be aware that autoSettle has implications of the possible transaction states # -> Be aware that autoSettle has implications of the possible transaction states
@ -76,6 +69,10 @@ def init_transaction(
}, },
} }
# add testing configuration data
if "fakeapi" in settings.DATATRANS_API_ENDPOINT:
payload["user_id"] = str(user.id)
logger.info("Initiating transaction", payload=payload) logger.info("Initiating transaction", payload=payload)
response = requests.post( response = requests.post(

View File

@ -0,0 +1,15 @@
from factory.django import DjangoModelFactory
from vbv_lernwelt.shop.const import VV_DE_PRODUCT_SKU
from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState
class CheckoutInformationFactory(DjangoModelFactory):
class Meta:
model = CheckoutInformation
product_sku = VV_DE_PRODUCT_SKU
product_price = 324_30
state = CheckoutState.PAID
product_name = "Versicherungsvermittler/-in VBV"
product_description = "Versicherungsvermittler/-in VBV DE"

View File

@ -0,0 +1,217 @@
from datetime import date, datetime
from django.test import TestCase
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.model_utils import add_countries
from vbv_lernwelt.shop.invoice.abacus import (
create_customer_xml,
create_invoice_xml,
render_customer_xml,
render_invoice_xml,
)
from vbv_lernwelt.shop.tests.factories import CheckoutInformationFactory
USER_USERNAME = "testuser"
USER_EMAIL = "test@example.com"
USER_PASSWORD = "testpassword"
class AbacusInvoiceTestCase(TestCase):
def setUp(self):
add_countries(small_set=True)
create_default_users()
def test_create_invoice_xml(self):
# set abacus_number before
_pat = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")
_pat.abacus_debitor_number = 60000011
_pat.save()
_ignore_checkout_information = CheckoutInformationFactory(
user=_pat, abacus_order_id=6_000_000_123
)
feuz = User.objects.get(username="andreas.feuz@eiger-versicherungen.ch")
feuz_checkout_info = CheckoutInformationFactory(
user=feuz,
transaction_id="24021508331287484",
)
feuz_checkout_info.created_at = datetime(2024, 2, 15, 8, 33, 12, 0)
invoice_xml_filename, invoice_xml_content = create_invoice_xml(
feuz_checkout_info
)
self.assertEqual(
invoice_xml_filename, "myVBV_orde_60000012_20240215083312_6000000124.xml"
)
print(invoice_xml_content)
assert "<CustomerNumber>60000012</CustomerNumber>" in invoice_xml_content
assert (
"<PurchaseOrderDate>2024-02-15</PurchaseOrderDate>" in invoice_xml_content
)
assert (
"<GroupingNumberAscii1>6000000124</GroupingNumberAscii1>"
in invoice_xml_content
)
assert (
"<ReferencePurchaseOrder>24021508331287484</ReferencePurchaseOrder>"
in invoice_xml_content
)
assert "<DeliveryDate>2024-02-15</DeliveryDate>" in invoice_xml_content
assert (
"<Text>Versicherungsvermittler/-in VBV, 2024-02-15, Feuz Andreas</Text>"
in invoice_xml_content
)
def test_render_invoice_xml(self):
invoice_xml = render_invoice_xml(
abacus_debitor_number=60000012,
abacus_order_id=6000000001,
order_date=date(2024, 2, 15),
datatrans_transaction_id="24021508331287484",
item_description="myVBV Versicherungsvermittler - Lernpfad, 2024-02-15, Skender, Gebhart-Krasniqi",
)
# example from Skender
expected_xml = """<?xml version="1.0" encoding="utf-8"?>
<AbaConnectContainer>
<Task>
<Parameter>
<Application>ORDE</Application>
<Id>Verkaufsauftrag</Id>
<MapId>AbaDefault</MapId>
<Version>2022.00</Version>
</Parameter>
<Transaction>
<SalesOrderHeader mode="SAVE">
<SalesOrderHeaderFields mode="SAVE">
<CustomerNumber>60000012</CustomerNumber>
<ProcessFlowNumber>30</ProcessFlowNumber>
<PaymentCode>9999</PaymentCode>
<PurchaseOrderDate>2024-02-15</PurchaseOrderDate>
<ReferencePurchaseOrder>24021508331287484</ReferencePurchaseOrder>
<GroupingNumberAscii1>6000000001</GroupingNumberAscii1>
</SalesOrderHeaderFields>
<Item mode="SAVE">
<ItemFields mode="SAVE">
<DeliveryDate>2024-02-15</DeliveryDate>
<ItemNumber>1</ItemNumber>
<ProductNumber>30202</ProductNumber>
<QuantityOrdered>1</QuantityOrdered>
</ItemFields>
<ItemText mode="SAVE">
<ItemTextFields mode="SAVE">
<Text>myVBV Versicherungsvermittler - Lernpfad, 2024-02-15, Skender, Gebhart-Krasniqi</Text>
</ItemTextFields>
</ItemText>
</Item>
</SalesOrderHeader>
</Transaction>
</Task>
</AbaConnectContainer>
"""
self.maxDiff = None
self.assertXMLEqual(expected_xml, invoice_xml)
def test_create_customer_xml_with_company_address(self):
_pat = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")
_pat.abacus_debitor_number = 60000011
_pat.save()
_ignore_checkout_information = CheckoutInformationFactory(
user=_pat, abacus_order_id=6_000_000_123
)
feuz = User.objects.get(username="andreas.feuz@eiger-versicherungen.ch")
feuz_checkout_info = CheckoutInformationFactory(
user=feuz,
transaction_id="24021508331287484",
first_name="Andreas",
last_name="Feuz",
street="Eggersmatt",
street_number="32",
postal_code="1719",
city="Zumholz",
country_id="CH",
invoice_address="org",
organisation_detail_name="VBV",
organisation_street="Laupenstrasse",
organisation_street_number="10",
organisation_postal_code="3000",
organisation_city="Bern",
organisation_country_id="CH",
)
feuz_checkout_info.created_at = datetime(2024, 2, 15, 8, 33, 12, 0)
customer_xml_filename, customer_xml_content = create_customer_xml(
checkout_information=feuz_checkout_info
)
print(customer_xml_content)
print(customer_xml_filename)
self.assertEqual("myVBV_debi_60000012.xml", customer_xml_filename)
assert "<CustomerNumber>60000012</CustomerNumber>" in customer_xml_content
assert (
"<Email>andreas.feuz@eiger-versicherungen.ch</Email>"
in customer_xml_content
)
assert "<AddressNumber>60000012</AddressNumber>" in customer_xml_content
assert "<Name>Feuz</Name>" in customer_xml_content
assert "<Text>VBV</Text>" in customer_xml_content
assert "<Street>Laupenstrasse</Street>" in customer_xml_content
assert "<Country>CH</Country>" in customer_xml_content
def test_render_customer_xml(self):
customer_xml = render_customer_xml(
abacus_debitor_number=60000012,
last_name="Gebhart-Krasniqi",
first_name="Skender",
company_name="VBV",
street="Laupenstrasse",
house_number="10",
zip_code="3000",
city="Bern",
country="CH",
language="de",
email="skender.krasniqi@vbv-afa.ch",
)
# example from Skender
expected_xml = """<?xml version="1.0" encoding="utf-8"?>
<AbaConnectContainer>
<Task>
<Parameter>
<Application>DEBI</Application>
<ID>Kunden</ID>
<MapID>AbaDefault</MapID>
<Version>2022.00</Version>
</Parameter>
<Transaction>
<Customer mode="SAVE">
<CustomerNumber>60000012</CustomerNumber>
<DefaultCurrency>CHF</DefaultCurrency>
<PaymentTermNumber>1</PaymentTermNumber>
<ReminderProcedure>NORM</ReminderProcedure>
<AddressData mode="SAVE">
<AddressNumber>60000012</AddressNumber>
<Name>Gebhart-Krasniqi</Name>
<FirstName>Skender</FirstName>
<Text>VBV</Text>
<Street>Laupenstrasse</Street>
<HouseNumber>10</HouseNumber>
<ZIP>3000</ZIP>
<City>Bern</City>
<Country>CH</Country>
<Language>de</Language>
<Email>skender.krasniqi@vbv-afa.ch</Email>
</AddressData>
</Customer>
</Transaction>
</Task>
</AbaConnectContainer>
"""
self.maxDiff = None
self.assertXMLEqual(expected_xml, customer_xml)

View File

@ -0,0 +1,31 @@
from django.test import TestCase
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.models import User
from vbv_lernwelt.shop.models import CheckoutInformation
from vbv_lernwelt.shop.tests.factories import CheckoutInformationFactory
class AbacusOrderIdTestCase(TestCase):
def setUp(self):
create_default_users()
self.pat = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")
self.feuz = User.objects.get(username="andreas.feuz@eiger-versicherungen.ch")
def test_set_increment_abacus_order_id(self):
checkout_info = CheckoutInformationFactory(user=self.pat)
self.assertIsNone(checkout_info.abacus_order_id)
checkout_info.set_increment_abacus_order_id()
self.assertEqual(checkout_info.abacus_order_id, 6_000_000_001)
checkout_info = CheckoutInformation.objects.get(id=checkout_info.id)
self.assertEqual(checkout_info.abacus_order_id, 6_000_000_001)
checkout_info = checkout_info.set_increment_abacus_order_id()
self.assertEqual(checkout_info.abacus_order_id, 6_000_000_001)
checkout_info2 = CheckoutInformationFactory(user=self.feuz)
checkout_info2 = checkout_info2.set_increment_abacus_order_id()
self.assertEqual(checkout_info2.abacus_order_id, 6_000_000_002)
checkout_info2 = CheckoutInformation.objects.get(id=checkout_info2.id)
self.assertEqual(checkout_info2.abacus_order_id, 6_000_000_002)

View File

@ -1,106 +0,0 @@
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

@ -21,13 +21,14 @@ TEST_ADDRESS_DATA = {
"street_number": "1", "street_number": "1",
"postal_code": "1234", "postal_code": "1234",
"city": "Test City", "city": "Test City",
"country": "209", "country_code": "CH",
"company_name": "Test Company", "invoice_address": "org",
"company_street": "Test Company Street", "organisation_detail_name": "Test Company",
"company_street_number": "1", "organisation_street": "Test Company Street",
"company_postal_code": "1234", "organisation_street_number": "1",
"company_city": "Test Company City", "organisation_postal_code": "1234",
"company_country": "209", "organisation_city": "Test Company City",
"organisation_country_code": "CH",
} }
REDIRECT_URL = "http://testserver/redirect-url" REDIRECT_URL = "http://testserver/redirect-url"
@ -50,40 +51,9 @@ class CheckoutAPITestCase(APITestCase):
) )
self.client.login(username=USER_USERNAME, password=USER_PASSWORD) self.client.login(username=USER_USERNAME, password=USER_PASSWORD)
add_countries() add_countries(small_set=True)
@patch("vbv_lernwelt.shop.views.init_transaction") @patch("vbv_lernwelt.shop.views.init_datatrans_transaction")
def test_checkout_no_company_address_updates_user(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": {
"first_name": "Test",
"last_name": "User",
"street": "Test Street",
"street_number": "1",
"postal_code": "1234",
"city": "Test City",
"country": "209",
# NO company data
},
},
)
# THEN
self.assertEqual(response.status_code, status.HTTP_200_OK)
user = User.objects.get(username=USER_USERNAME)
self.assertEqual(user.invoice_address, User.INVOICE_ADDRESS_PRIVATE)
@patch("vbv_lernwelt.shop.views.init_transaction")
def test_checkout_happy_case(self, mock_init_transaction): def test_checkout_happy_case(self, mock_init_transaction):
# GIVEN # GIVEN
mock_init_transaction.return_value = "1234567890" mock_init_transaction.return_value = "1234567890"
@ -106,13 +76,12 @@ class CheckoutAPITestCase(APITestCase):
response.json()["next_step_url"], response.json()["next_step_url"],
) )
self.assertTrue( ci = CheckoutInformation.objects.first()
CheckoutInformation.objects.filter( self.assertEqual(ci.first_name, "Test")
user=self.user, self.assertEqual(ci.last_name, "User")
product_sku=VV_DE_PRODUCT_SKU, self.assertEqual(ci.country_id, "CH")
state=CheckoutState.ONGOING, self.assertEqual(ci.state, "ongoing")
).exists() self.assertEqual(ci.transaction_id, "1234567890")
)
mock_init_transaction.assert_called_once_with( mock_init_transaction.assert_called_once_with(
user=self.user, user=self.user,
@ -123,13 +92,7 @@ class CheckoutAPITestCase(APITestCase):
webhook_url=f"{REDIRECT_URL}/api/shop/transaction/webhook/", webhook_url=f"{REDIRECT_URL}/api/shop/transaction/webhook/",
) )
user = User.objects.get(username=USER_USERNAME) @patch("vbv_lernwelt.shop.views.init_datatrans_transaction")
self.assertEqual(user.street, TEST_ADDRESS_DATA["street"])
self.assertEqual(str(user.country.country_id), TEST_ADDRESS_DATA["country"])
self.assertEqual(user.invoice_address, User.INVOICE_ADDRESS_ORGANISATION)
@patch("vbv_lernwelt.shop.views.init_transaction")
def test_incomplete_setup(self, mock_init_transaction): def test_incomplete_setup(self, mock_init_transaction):
# GIVEN # GIVEN
Product.objects.all().delete() Product.objects.all().delete()
@ -156,7 +119,7 @@ class CheckoutAPITestCase(APITestCase):
self.assertEqual(expected, response.json()["next_step_url"]) self.assertEqual(expected, response.json()["next_step_url"])
@patch("vbv_lernwelt.shop.views.init_transaction") @patch("vbv_lernwelt.shop.views.init_datatrans_transaction")
def test_checkout_init_transaction_exception(self, mock_init_transaction): def test_checkout_init_transaction_exception(self, mock_init_transaction):
# GIVEN # GIVEN
mock_init_transaction.side_effect = InitTransactionException( mock_init_transaction.side_effect = InitTransactionException(
@ -213,7 +176,7 @@ class CheckoutAPITestCase(APITestCase):
response.json()["next_step_url"], response.json()["next_step_url"],
) )
@patch("vbv_lernwelt.shop.views.init_transaction") @patch("vbv_lernwelt.shop.views.init_datatrans_transaction")
def test_checkout_double_checkout(self, mock_init_transaction): def test_checkout_double_checkout(self, mock_init_transaction):
"""Advise by Datatrans: Just create a new transaction.""" """Advise by Datatrans: Just create a new transaction."""
# GIVEN # GIVEN
@ -277,7 +240,7 @@ class CheckoutAPITestCase(APITestCase):
).exists() ).exists()
) )
@patch("vbv_lernwelt.shop.views.init_transaction") @patch("vbv_lernwelt.shop.views.init_datatrans_transaction")
def test_checkout_failed_creates_new(self, mock_init_transaction): def test_checkout_failed_creates_new(self, mock_init_transaction):
# GIVEN # GIVEN
state = CheckoutState.FAILED state = CheckoutState.FAILED
@ -310,7 +273,7 @@ class CheckoutAPITestCase(APITestCase):
response.json()["next_step_url"], response.json()["next_step_url"],
) )
@patch("vbv_lernwelt.shop.views.init_transaction") @patch("vbv_lernwelt.shop.views.init_datatrans_transaction")
def test_checkout_cancelled_creates_new(self, mock_init_transaction): def test_checkout_cancelled_creates_new(self, mock_init_transaction):
# GIVEN # GIVEN
state = CheckoutState.CANCELED state = CheckoutState.CANCELED

View File

@ -6,7 +6,7 @@ from django.test import override_settings, TestCase
from vbv_lernwelt.core.models import User from vbv_lernwelt.core.models import User
from vbv_lernwelt.shop.services import ( from vbv_lernwelt.shop.services import (
get_payment_url, get_payment_url,
init_transaction, init_datatrans_transaction,
InitTransactionException, InitTransactionException,
) )
@ -36,7 +36,7 @@ class DatatransServiceTest(TestCase):
self.user.language = "it" self.user.language = "it"
# WHEN # WHEN
transaction_id = init_transaction( transaction_id = init_datatrans_transaction(
user=self.user, user=self.user,
amount_chf_centimes=324_30, amount_chf_centimes=324_30,
redirect_url_success=f"{REDIRECT_URL}/success", redirect_url_success=f"{REDIRECT_URL}/success",
@ -76,7 +76,7 @@ class DatatransServiceTest(TestCase):
# WHEN / THEN # WHEN / THEN
with self.assertRaises(InitTransactionException): with self.assertRaises(InitTransactionException):
init_transaction( init_datatrans_transaction(
user=self.user, user=self.user,
amount_chf_centimes=324_30, amount_chf_centimes=324_30,
redirect_url_success=f"/success", redirect_url_success=f"/success",

View File

@ -5,6 +5,7 @@ from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from vbv_lernwelt.core.admin import User from vbv_lernwelt.core.admin import User
from vbv_lernwelt.core.model_utils import add_countries
from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN_ID 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.creators.test_utils import create_course, create_course_session
from vbv_lernwelt.course.models import CourseSessionUser from vbv_lernwelt.course.models import CourseSessionUser
@ -29,6 +30,8 @@ def create_checkout_information(
class DatatransWebhookTestCase(APITestCase): class DatatransWebhookTestCase(APITestCase):
def setUp(self) -> None: def setUp(self) -> None:
add_countries(small_set=True)
course, _ = create_course( course, _ = create_course(
title="VV_in_DE", title="VV_in_DE",
# needed for VV_DE_PRODUCT_SKU # needed for VV_DE_PRODUCT_SKU
@ -102,13 +105,13 @@ class DatatransWebhookTestCase(APITestCase):
checkout_info.street_number = "1" checkout_info.street_number = "1"
checkout_info.postal_code = "1234" checkout_info.postal_code = "1234"
checkout_info.city = "Musterstadt" checkout_info.city = "Musterstadt"
checkout_info.country = "Schweiz" checkout_info.country_id = "CH"
checkout_info.company_name = "Musterfirma" checkout_info.organisation_detail_name = "Musterfirma"
checkout_info.company_street = "Firmastrasse" checkout_info.organisation_street = "Firmastrasse"
checkout_info.company_street_number = "2" checkout_info.organisation_street_number = "2"
checkout_info.company_postal_code = "5678" checkout_info.organisation_postal_code = "5678"
checkout_info.company_city = "Firmastadt" checkout_info.organisation_city = "Firmastadt"
checkout_info.company_country = "Schweiz" checkout_info.organisation_country_id = "CH"
checkout_info.save() checkout_info.save()
mock_is_signature_valid.return_value = True mock_is_signature_valid.return_value = True
@ -181,10 +184,10 @@ class DatatransWebhookTestCase(APITestCase):
"target_url": "https://my.vbv-afa.ch/", "target_url": "https://my.vbv-afa.ch/",
"name": "Max Mustermann", "name": "Max Mustermann",
"private_street": "Musterstrasse 1", "private_street": "Musterstrasse 1",
"private_city": "1234 Musterstadt Schweiz", "private_city": "CH-1234 Musterstadt",
"company_name": "Musterfirma", "company_name": "Musterfirma",
"company_street": "Firmastrasse 2", "company_street": "Firmastrasse 2",
"company_city": "5678 Firmastadt Schweiz", "company_city": "CH-5678 Firmastadt",
}, },
template_language=self.user.language, template_language=self.user.language,
fail_silently=ANY, fail_silently=ANY,

View File

@ -1,86 +0,0 @@
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

@ -1,18 +1,9 @@
from django.urls import path from django.urls import path
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
from vbv_lernwelt.shop.views import ( from vbv_lernwelt.shop.views import checkout_vv, transaction_webhook
checkout_vv,
get_billing_address,
transaction_webhook,
update_billing_address,
)
urlpatterns = [ 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("vv/checkout/", checkout_vv, name="checkout-vv"),
path( path(
"transaction/webhook/", "transaction/webhook/",

View File

@ -1,13 +1,10 @@
import structlog import structlog
from django.conf import settings from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
from vbv_lernwelt.core.models import Country, User
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.notify.email.email_services import EmailTemplate, send_email from vbv_lernwelt.notify.email.email_services import EmailTemplate, send_email
from vbv_lernwelt.shop.const import ( from vbv_lernwelt.shop.const import (
@ -15,17 +12,11 @@ from vbv_lernwelt.shop.const import (
VV_FR_PRODUCT_SKU, VV_FR_PRODUCT_SKU,
VV_IT_PRODUCT_SKU, VV_IT_PRODUCT_SKU,
) )
from vbv_lernwelt.shop.models import ( from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState, Product
BillingAddress,
CheckoutInformation,
CheckoutState,
Product,
)
from vbv_lernwelt.shop.serializers import BillingAddressSerializer
from vbv_lernwelt.shop.services import ( from vbv_lernwelt.shop.services import (
datatrans_state_to_checkout_state, datatrans_state_to_checkout_state,
get_payment_url, get_payment_url,
init_transaction, init_datatrans_transaction,
InitTransactionException, InitTransactionException,
is_signature_valid, is_signature_valid,
) )
@ -40,39 +31,6 @@ PRODUCT_SKU_TO_COURSE_SESSION_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"]) @api_view(["POST"])
def transaction_webhook(request): def transaction_webhook(request):
"""IMPORTANT: This is not called for timed out transactions!""" """IMPORTANT: This is not called for timed out transactions!"""
@ -125,7 +83,7 @@ def checkout_vv(request):
sku = request.data["product"] sku = request.data["product"]
base_redirect_url = request.data["redirect_url"] base_redirect_url = request.data["redirect_url"]
logger.info(f"Checkout requested: sku={sku}", user_id=request.user.id) logger.info(f"Checkout requested: sku", user_id=request.user.id, sku=sku)
try: try:
product = Product.objects.get(sku=sku) product = Product.objects.get(sku=sku)
@ -149,7 +107,7 @@ def checkout_vv(request):
return next_step_response(url="/") return next_step_response(url="/")
try: try:
transaction_id = init_transaction( transaction_id = init_datatrans_transaction(
user=request.user, user=request.user,
amount_chf_centimes=product.price, amount_chf_centimes=product.price,
redirect_url_success=checkout_success_url( redirect_url_success=checkout_success_url(
@ -171,6 +129,15 @@ def checkout_vv(request):
), ),
) )
address_data = request.data["address"]
country_code = address_data.pop("country_code")
address_data["country_id"] = country_code
organisation_country_code = "CH"
if "organisation_country_code" in address_data:
organisation_country_code = address_data.pop("organisation_country_code")
address_data["organisation_country_id"] = organisation_country_code
checkout_info = CheckoutInformation.objects.create( checkout_info = CheckoutInformation.objects.create(
user=request.user, user=request.user,
state=CheckoutState.ONGOING, state=CheckoutState.ONGOING,
@ -184,8 +151,6 @@ def checkout_vv(request):
**request.data["address"], **request.data["address"],
) )
update_user_address(user=request.user, checkout_info=checkout_info)
return next_step_response(url=get_payment_url(transaction_id)) return next_step_response(url=get_payment_url(transaction_id))
@ -209,10 +174,10 @@ def send_vv_welcome_email(checkout_info: CheckoutInformation):
"target_url": "https://my.vbv-afa.ch/", "target_url": "https://my.vbv-afa.ch/",
"name": f"{checkout_info.first_name} {checkout_info.last_name}", "name": f"{checkout_info.first_name} {checkout_info.last_name}",
"private_street": f"{checkout_info.street} {checkout_info.street_number}", "private_street": f"{checkout_info.street} {checkout_info.street_number}",
"private_city": f"{checkout_info.postal_code} {checkout_info.city} {checkout_info.country}", "private_city": f"{checkout_info.country_id}-{checkout_info.postal_code} {checkout_info.city}",
"company_name": checkout_info.company_name, "company_name": checkout_info.organisation_detail_name,
"company_street": f"{checkout_info.company_street} {checkout_info.company_street_number}", "company_street": f"{checkout_info.organisation_street} {checkout_info.organisation_street_number}",
"company_city": f"{checkout_info.company_postal_code} {checkout_info.company_city} {checkout_info.company_country}", "company_city": f"{checkout_info.organisation_country_id}-{checkout_info.organisation_postal_code} {checkout_info.organisation_city}",
}, },
template_language=checkout_info.user.language, template_language=checkout_info.user.language,
fail_silently=True, fail_silently=True,
@ -266,35 +231,3 @@ def checkout_cancel_url(base_url: str) -> str:
def checkout_success_url(product_sku: str, base_url: str = "") -> str: def checkout_success_url(product_sku: str, base_url: str = "") -> str:
return f"{base_url}/onboarding/{product_sku}/checkout/complete" return f"{base_url}/onboarding/{product_sku}/checkout/complete"
def update_user_address(user: User, checkout_info: CheckoutInformation):
user.street = checkout_info.street
user.street_number = checkout_info.street_number
user.postal_code = checkout_info.postal_code
user.city = checkout_info.city
if checkout_info.country:
user.country = Country.objects.filter(country_id=checkout_info.country).first()
if (
checkout_info.company_name
and checkout_info.company_street
and checkout_info.company_street_number
and checkout_info.company_postal_code
and checkout_info.company_city
and checkout_info.company_country
):
user.organisation_detail_name = checkout_info.company_name
user.organisation_street = checkout_info.company_street
user.organisation_street_number = checkout_info.company_street_number
user.organisation_postal_code = checkout_info.company_postal_code
user.organisation_city = checkout_info.company_city
user.organisation_country = Country.objects.filter(
country_id=checkout_info.company_country
).first()
user.invoice_address = User.INVOICE_ADDRESS_ORGANISATION
user.save()

10
start_sftpserver.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/bash
# set location to script directory
cd "${0%/*}"
# start python sftp test server (for abacus exports)
rm -rf sftptest
mkdir -p sftptest/debitor
mkdir -p sftptest/order
(cd sftptest && sftpserver -p 3373)