diff --git a/caprover_create_app.py b/caprover_create_app.py index 31f14b15..24dba123 100644 --- a/caprover_create_app.py +++ b/caprover_create_app.py @@ -91,10 +91,10 @@ def main(app_name, image_name, environment_file): "AWS_S3_ACCESS_KEY_ID", "AKIAZJLREPUVWNBTJ5VY" ), "AWS_S3_SECRET_ACCESS_KEY": env.str("AWS_S3_SECRET_ACCESS_KEY", ""), - "AWS_S3_REGION_NAME": env.str("AWS_S3_REGION_NAME", "eu-central-1"), - "AWS_STORAGE_BUCKET_NAME": env.str( - "AWS_STORAGE_BUCKET_NAME", "myvbv-dev.iterativ.ch" - ), + "AWS_S3_REGION_NAME": "eu-central-1", + "AWS_STORAGE_BUCKET_NAME": "myvbv-dev.iterativ.ch", + "DATATRANS_HMAC_KEY": env.str("DATATRANS_HMAC_KEY", ""), + "DATATRANS_BASIC_AUTH_KEY": env.str("DATATRANS_BASIC_AUTH_KEY", ""), "FILE_UPLOAD_STORAGE": "s3", "IT_DJANGO_DEBUG": "false", "IT_SERVE_VUE": "false", diff --git a/caprover_deploy.sh b/caprover_deploy.sh index efb90f50..a92d63e2 100755 --- a/caprover_deploy.sh +++ b/caprover_deploy.sh @@ -29,7 +29,7 @@ APP_NAME=${1:-$(generate_default_app_name)} export VITE_APP_ENVIRONMENT="dev-$APP_NAME" if [[ "$APP_NAME" == "myvbv-stage" ]]; then - export VITE_OAUTH_API_BASE_URL="https://vbvtst.b2clogin.com/vbvtst.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/" + export VITE_OAUTH_API_BASE_URL="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/" export VITE_APP_ENVIRONMENT="stage-caprover" elif [[ "$APP_NAME" == prod* ]]; then export VITE_OAUTH_API_BASE_URL="https://edumgr.b2clogin.com/edumgr.onmicrosoft.com/b2c_1_signupandsignin/oauth2/v2.0/" diff --git a/client/src/App.vue b/client/src/App.vue index 1e6b0e63..5cbafefb 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,12 +1,12 @@ @@ -19,6 +19,9 @@ import { graphqlClient } from "@/graphql/client"; import eventBus from "@/utils/eventBus"; import { provideClient } from "@urql/vue"; import { onMounted, ref } from "vue"; +import { useRoute } from "vue-router"; + +const route = useRoute(); const componentKey = ref(1); diff --git a/client/src/assets/images/mood_uk.jpg b/client/src/assets/images/mood_uk.jpg new file mode 100644 index 00000000..ece7d4be Binary files /dev/null and b/client/src/assets/images/mood_uk.jpg differ diff --git a/client/src/assets/images/mood_vv.jpg b/client/src/assets/images/mood_vv.jpg new file mode 100644 index 00000000..49033e39 Binary files /dev/null and b/client/src/assets/images/mood_vv.jpg differ diff --git a/client/src/components/dashboard/NoCourseSession.vue b/client/src/components/dashboard/NoCourseSession.vue new file mode 100644 index 00000000..4386d269 --- /dev/null +++ b/client/src/components/dashboard/NoCourseSession.vue @@ -0,0 +1,48 @@ + + + diff --git a/client/src/components/header/MainNavigationBar.vue b/client/src/components/header/MainNavigationBar.vue index 12063878..8692418a 100644 --- a/client/src/components/header/MainNavigationBar.vue +++ b/client/src/components/header/MainNavigationBar.vue @@ -1,6 +1,6 @@ + + diff --git a/client/src/components/onboarding/OrganisationAddress.vue b/client/src/components/onboarding/OrganisationAddress.vue new file mode 100644 index 00000000..d0f4d2fd --- /dev/null +++ b/client/src/components/onboarding/OrganisationAddress.vue @@ -0,0 +1,151 @@ + + + diff --git a/client/src/components/onboarding/PersonalAddress.vue b/client/src/components/onboarding/PersonalAddress.vue new file mode 100644 index 00000000..0dedbbf5 --- /dev/null +++ b/client/src/components/onboarding/PersonalAddress.vue @@ -0,0 +1,163 @@ + + + diff --git a/client/src/components/onboarding/WizardPage.vue b/client/src/components/onboarding/WizardPage.vue new file mode 100644 index 00000000..570081c0 --- /dev/null +++ b/client/src/components/onboarding/WizardPage.vue @@ -0,0 +1,23 @@ + + + diff --git a/client/src/components/onboarding/WizardSidePanel.vue b/client/src/components/onboarding/WizardSidePanel.vue new file mode 100644 index 00000000..7c605119 --- /dev/null +++ b/client/src/components/onboarding/WizardSidePanel.vue @@ -0,0 +1,108 @@ + + diff --git a/client/src/components/start/StartPageFooter.vue b/client/src/components/start/StartPageFooter.vue new file mode 100644 index 00000000..733c18a4 --- /dev/null +++ b/client/src/components/start/StartPageFooter.vue @@ -0,0 +1,16 @@ + + + diff --git a/client/src/components/ui/AvatarImage.vue b/client/src/components/ui/AvatarImage.vue new file mode 100644 index 00000000..bf0cbc87 --- /dev/null +++ b/client/src/components/ui/AvatarImage.vue @@ -0,0 +1,58 @@ + + + diff --git a/client/src/components/ui/LoadingSpinner.vue b/client/src/components/ui/LoadingSpinner.vue index 34c7ca21..44a78179 100644 --- a/client/src/components/ui/LoadingSpinner.vue +++ b/client/src/components/ui/LoadingSpinner.vue @@ -2,7 +2,7 @@
-
-
-

- {{ $t("dashboard.welcome", { name: userStore.first_name }) }} -

-
-
-

{{ $t("dashboard.courses") }}

- -
-
-
-

{{ courseSession.course.title }}

-
- -
-
- - {{ $t("general.nextStep") }} - -
-
-
-
-
-
-

{{ $t("dashboard.dueDatesTitle") }}

- -
-
-
-
-

{{ $t("dashboard.courses") }}

-

{{ $t("uk.dashboard.welcome") }}

-

{{ $t("uk.dashboard.nextSteps") }}

-
-
-

{{ $t("uk.dashboard.allClear") }}

-

{{ $t("footer.contact") }}

-
-

- {{ $t("uk.contact.title") }} -
- {{ $t("uk.contact.team") }} -
- {{ $t("uk.contact.address") }} -
- - uek-support@vbv-afa.ch - -

-
-
-
-
-
- -
- - - diff --git a/client/src/pages/LoginPage.vue b/client/src/pages/LoginPage.vue index 0997e487..d65cb57d 100644 --- a/client/src/pages/LoginPage.vue +++ b/client/src/pages/LoginPage.vue @@ -1,16 +1,11 @@ + + diff --git a/client/src/pages/onboarding/AccountProfile.vue b/client/src/pages/onboarding/AccountProfile.vue new file mode 100644 index 00000000..1eb9bb78 --- /dev/null +++ b/client/src/pages/onboarding/AccountProfile.vue @@ -0,0 +1,123 @@ + + + diff --git a/client/src/pages/onboarding/AccountSetup.vue b/client/src/pages/onboarding/AccountSetup.vue new file mode 100644 index 00000000..eeba2b7c --- /dev/null +++ b/client/src/pages/onboarding/AccountSetup.vue @@ -0,0 +1,43 @@ + + + diff --git a/client/src/pages/onboarding/WizardBase.vue b/client/src/pages/onboarding/WizardBase.vue new file mode 100644 index 00000000..82116103 --- /dev/null +++ b/client/src/pages/onboarding/WizardBase.vue @@ -0,0 +1,49 @@ + + + diff --git a/client/src/pages/onboarding/uk/SetupComplete.vue b/client/src/pages/onboarding/uk/SetupComplete.vue new file mode 100644 index 00000000..1511dd2d --- /dev/null +++ b/client/src/pages/onboarding/uk/SetupComplete.vue @@ -0,0 +1,66 @@ + + + diff --git a/client/src/pages/onboarding/vv/CheckoutAddress.vue b/client/src/pages/onboarding/vv/CheckoutAddress.vue new file mode 100644 index 00000000..2d7c9271 --- /dev/null +++ b/client/src/pages/onboarding/vv/CheckoutAddress.vue @@ -0,0 +1,324 @@ + + + diff --git a/client/src/pages/onboarding/vv/CheckoutComplete.vue b/client/src/pages/onboarding/vv/CheckoutComplete.vue new file mode 100644 index 00000000..cceb0a5d --- /dev/null +++ b/client/src/pages/onboarding/vv/CheckoutComplete.vue @@ -0,0 +1,50 @@ + + + diff --git a/client/src/pages/onboarding/vv/composables.ts b/client/src/pages/onboarding/vv/composables.ts new file mode 100644 index 00000000..efc38658 --- /dev/null +++ b/client/src/pages/onboarding/vv/composables.ts @@ -0,0 +1,18 @@ +import { useTranslation } from "i18next-vue"; + +export const getVVCourseName = (courseType: string) => { + const { t } = useTranslation(); + + if (!["vv-de", "vv-it", "vv-fr"].includes(courseType)) { + return ""; + } + + const lookup: { [key: string]: string } = { + "vv-de": "Deutsch", + "vv-fr": "Français", + "vv-it": "Italiano", + }; + + const vv = t("a.Versicherungsvermittler/-in"); + return `${vv} (${lookup[courseType]})`; +}; diff --git a/client/src/pages/start/GuestStartPage.vue b/client/src/pages/start/GuestStartPage.vue new file mode 100644 index 00000000..66659662 --- /dev/null +++ b/client/src/pages/start/GuestStartPage.vue @@ -0,0 +1,58 @@ + + + diff --git a/client/src/pages/start/UKStartPage.vue b/client/src/pages/start/UKStartPage.vue new file mode 100644 index 00000000..6a2b6d9f --- /dev/null +++ b/client/src/pages/start/UKStartPage.vue @@ -0,0 +1,79 @@ + + + diff --git a/client/src/pages/start/VVStartPage.vue b/client/src/pages/start/VVStartPage.vue new file mode 100644 index 00000000..3d7ba6fc --- /dev/null +++ b/client/src/pages/start/VVStartPage.vue @@ -0,0 +1,96 @@ + + + diff --git a/client/src/router/__tests__/onboarding.spec.ts b/client/src/router/__tests__/onboarding.spec.ts new file mode 100644 index 00000000..199bc5e0 --- /dev/null +++ b/client/src/router/__tests__/onboarding.spec.ts @@ -0,0 +1,110 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, vi } from "vitest"; + +import { START_LOCATION } from "vue-router"; +import { useUserStore } from "../../stores/user"; +import { onboardingRedirect } from "../onboarding"; + +describe("Onboarding", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it("redirect guest", () => { + const user = useUserStore(); + user.loggedIn = false; + const mock = vi.fn(); + onboardingRedirect(routeLocation("accountConfirm", "uk"), START_LOCATION, mock); + expect(mock).toHaveBeenCalledWith({ + name: "accountCreate", + params: { courseType: "uk" }, + }); + }); + + it("redirect logged-in user from accountCreate to accountConfirm", () => { + const user = useUserStore(); + user.loggedIn = true; + const mockNext = vi.fn(); + onboardingRedirect(routeLocation("accountCreate", "uk"), START_LOCATION, mockNext); + expect(mockNext).toHaveBeenCalledWith({ + name: "accountConfirm", + params: { courseType: "uk" }, + }); + }); + + it("UK: redirect to profile next route for logged-in user with organisation", () => { + const user = useUserStore(); + user.loggedIn = true; + user.organisation = 1; + const mockNext = vi.fn(); + onboardingRedirect(routeLocation("accountProfile", "uk"), START_LOCATION, mockNext); + expect(mockNext).toHaveBeenCalledWith({ + name: "setupComplete", + params: { courseType: "uk" }, + }); + }); + + it("VV: redirect to profile next route for logged-in user with organisation", () => { + const testCases = ["vv-de", "vv-it", "vv-fr"]; + testCases.forEach((testCase) => { + const user = useUserStore(); + user.loggedIn = true; + user.organisation = 1; + const mockNext = vi.fn(); + onboardingRedirect( + routeLocation("accountProfile", testCase), + START_LOCATION, + mockNext + ); + expect(mockNext).toHaveBeenCalledWith({ + name: "checkoutAddress", + params: { courseType: testCase }, + }); + }); + }); + + it("no redirect for logged-in user without organisation to accountConfirm", () => { + const user = useUserStore(); + user.loggedIn = true; + user.organisation = null; + const mockNext = vi.fn(); + onboardingRedirect(routeLocation("accountConfirm", "uk"), START_LOCATION, mockNext); + expect(mockNext).toHaveBeenCalledWith(); // No arguments passed means no redirection + }); + + it("no redirect for logged-in user to a non-relevant route", () => { + const user = useUserStore(); + user.loggedIn = true; + const mockNext = vi.fn(); + onboardingRedirect(routeLocation("someOtherRoute", "uk"), START_LOCATION, mockNext); + expect(mockNext).toHaveBeenCalledWith(); // No arguments passed means no redirection + }); + + it("no redirect for guest on accountCreate page", () => { + const user = useUserStore(); + user.loggedIn = false; + const mockNext = vi.fn(); + onboardingRedirect(routeLocation("accountCreate", "uk"), START_LOCATION, mockNext); + expect(mockNext).toHaveBeenCalledWith(); // No arguments passed means no redirection + }); +}); + +function routeLocation(name: string, courseType: string) { + return { + fullPath: "", + hash: "", + matched: [], + meta: {}, + name: name, + params: { + courseType, + }, + path: "", + query: {}, + redirectedFrom: undefined, + }; +} diff --git a/client/src/router/guards.ts b/client/src/router/guards.ts index 4899cf7b..f510d1bb 100644 --- a/client/src/router/guards.ts +++ b/client/src/router/guards.ts @@ -1,3 +1,4 @@ +import { getLoginURLNext, shouldUseSSO } from "@/router/utils"; import { useCockpitStore } from "@/stores/cockpit"; import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useUserStore } from "@/stores/user"; @@ -13,14 +14,26 @@ export const updateLoggedIn: NavigationGuard = async () => { } }; -export const redirectToLoginIfRequired: NavigationGuard = (to) => { - const userStore = useUserStore(); - if (loginRequired(to) && !userStore.loggedIn) { - const appEnv = import.meta.env.VITE_APP_ENVIRONMENT || "local"; - const ssoLogin = appEnv.startsWith("prod") || appEnv.startsWith("stage"); - return ssoLogin - ? `/login?next=${encodeURIComponent(to.fullPath)}` - : `/login-local?next=${encodeURIComponent(to.fullPath)}`; +export const redirectToLoginIfRequired: NavigationGuard = (to, from, next) => { + const user = useUserStore(); + + // redirect guests to /start if they access / + if (!user.loggedIn && to.path === "/") { + return next("/start"); + } + + if (loginRequired(to) && !user.loggedIn) { + const loginURL = getLoginURLNext(); + if (shouldUseSSO()) { + // Redirect to SSO login page, handled by the server + window.location.href = loginURL; + } else { + // Handle local login with Vue router + next(loginURL); + } + } else { + // If login is not required or user is already logged in, continue with the navigation + next(); } }; diff --git a/client/src/router/index.ts b/client/src/router/index.ts index c54a3044..e20ac9a7 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -1,5 +1,8 @@ -import DashboardPage from "@/pages/dashboard/DashboardPage.vue"; import LoginPage from "@/pages/LoginPage.vue"; +import DashboardPage from "@/pages/dashboard/DashboardPage.vue"; +import GuestStartPage from "@/pages/start/GuestStartPage.vue"; +import UKStartPage from "@/pages/start/UKStartPage.vue"; +import VVStartPage from "@/pages/start/VVStartPage.vue"; import { handleAcceptLearningMentorInvitation, handleCockpit, @@ -9,6 +12,7 @@ import { updateLoggedIn, } from "@/router/guards"; import { addToHistory } from "@/router/history"; +import { onboardingRedirect } from "@/router/onboarding"; import { createRouter, createWebHistory } from "vue-router"; const router = createRouter({ @@ -22,20 +26,33 @@ const router = createRouter({ }, routes: [ { - path: "/login", - component: LoginPage, - props: { loginMethod: "sso" }, + path: "/start", + name: "start", + component: GuestStartPage, + meta: { + public: true, + }, + }, + { + path: "/start/vv", + component: VVStartPage, + name: "vvStart", + meta: { + public: true, + }, + }, + { + path: "/start/uk", + component: UKStartPage, + name: "ukStart", meta: { - // no login required -> so `public === true` public: true, }, }, { path: "/login-local", component: LoginPage, - props: { loginMethod: "local" }, meta: { - // no login required -> so `public === true` public: true, }, }, @@ -300,6 +317,50 @@ const router = createRouter({ path: "/course/:courseSlug/appointments", component: () => import("@/pages/AppointmentsPage.vue"), }, + { + path: "/onboarding/:courseType", + props: true, + component: () => import("@/pages/onboarding/WizardBase.vue"), + meta: { + public: true, + hideChrome: true, + }, + beforeEnter: onboardingRedirect, + children: [ + { + path: "account/create", + component: () => import("@/pages/onboarding/AccountSetup.vue"), + name: "accountCreate", + props: true, + }, + { + path: "account/confirm", + component: () => import("@/pages/onboarding/AccountConfirm.vue"), + name: "accountConfirm", + }, + { + path: "account/profile", + component: () => import("@/pages/onboarding/AccountProfile.vue"), + name: "accountProfile", + }, + { + path: "account/complete", + component: () => import("@/pages/onboarding/uk/SetupComplete.vue"), + name: "setupComplete", + }, + { + path: "checkout/address", + component: () => import("@/pages/onboarding/vv/CheckoutAddress.vue"), + name: "checkoutAddress", + props: true, + }, + { + path: "checkout/complete", + component: () => import("@/pages/onboarding/vv/CheckoutComplete.vue"), + name: "checkoutComplete", + }, + ], + }, { path: "/styleguide", component: () => import("../pages/StyleGuidePage.vue"), @@ -307,6 +368,7 @@ const router = createRouter({ public: true, }, }, + { path: "/:pathMatch(.*)*", component: () => import("../pages/404Page.vue"), diff --git a/client/src/router/onboarding.ts b/client/src/router/onboarding.ts new file mode 100644 index 00000000..3221b388 --- /dev/null +++ b/client/src/router/onboarding.ts @@ -0,0 +1,36 @@ +import { profileNextRoute } from "@/services/onboarding"; +import { useUserStore } from "@/stores/user"; +import type { NavigationGuardNext, RouteLocationNormalized } from "vue-router"; +import { START_LOCATION } from "vue-router"; + +export async function onboardingRedirect( + to: RouteLocationNormalized, + from: RouteLocationNormalized, + next: NavigationGuardNext +) { + const user = useUserStore(); + + // Guest + if (!user.loggedIn) { + if (to.name !== "accountCreate") { + return next({ name: "accountCreate", params: to.params }); + } + return next(); + } + + // Member + if (to.name === "accountCreate") { + return next({ name: "accountConfirm", params: to.params }); + } + + if (to.name === "accountConfirm") { + return next(); + } + + // Maybe we can skip the profile step + if (from === START_LOCATION && user.organisation && to.name === "accountProfile") { + return next({ name: profileNextRoute(to.params.courseType), params: to.params }); + } + + return next(); +} diff --git a/client/src/router/utils.ts b/client/src/router/utils.ts new file mode 100644 index 00000000..62d1d920 --- /dev/null +++ b/client/src/router/utils.ts @@ -0,0 +1,19 @@ +export function shouldUseSSO() { + const appEnv = import.meta.env.VITE_APP_ENVIRONMENT || "local"; + return appEnv.startsWith("prod") || appEnv.startsWith("stage"); +} + +export function getLoginURL(params = {}) { + let url = shouldUseSSO() ? "/sso/login/" : "/login-local"; + + const queryParams = new URLSearchParams(params); + if (queryParams.toString()) { + url += `?${queryParams}`; + } + + return url; +} + +export function getLoginURLNext() { + return getLoginURL({ next: window.location.pathname }); +} diff --git a/client/src/services/files.ts b/client/src/services/files.ts index 3a6ae62d..37d18cfd 100644 --- a/client/src/services/files.ts +++ b/client/src/services/files.ts @@ -107,7 +107,19 @@ export async function fetchCourseSessionDocuments(courseSessionId: string) { return itGetCached(`/api/core/document/list/${courseSessionId}/`); } -export async function presignUpload(file: File) { +type PresignResponse = { + pre_sign: { + url: string; + fields: Record; + }; + file_info: { + id: string; + name: string; + url: string; + }; +}; + +export async function presignUpload(file: File): Promise { return await itPost(`/api/core/storage/presign/`, { file_type: file.type, file_name: file.name, diff --git a/client/src/services/onboarding.ts b/client/src/services/onboarding.ts new file mode 100644 index 00000000..da1bdb9a --- /dev/null +++ b/client/src/services/onboarding.ts @@ -0,0 +1,66 @@ +import { itGetCached } from "@/fetchHelpers"; +import { useUserStore } from "@/stores/user"; +import { isString, startsWith } from "lodash"; +import type { Ref } from "vue"; +import { computed, ref } from "vue"; + +export function profileNextRoute(courseType: string | string[]) { + if (courseType === "uk") { + return "setupComplete"; + } + // vv- -> vv-de, vv-fr or vv-it + if (isString(courseType) && startsWith(courseType, "vv-")) { + return "checkoutAddress"; + } + return ""; +} + +export type Organisation = { + organisation_id: number; + name_de: string; + name_fr: string; + name_it: string; +}; + +export type Country = { + country_id: number; + name_de: string; + name_fr: string; + name_it: string; +}; + +export type Entities = { + organisations: Organisation[]; + countries: Country[]; +}; + +export function useEntities() { + const user = useUserStore(); + const entities: Ref = ref(); + + itGetCached("/api/core/entities/").then((res) => { + entities.value = res; + }); + + const organisations = computed(() => { + if (entities.value) { + return entities.value.organisations.map((c) => ({ + id: c.organisation_id, + name: c[`name_${user.language}`], + })); + } + return []; + }); + + const countries = computed(() => { + if (entities.value) { + return entities.value.countries.map((c) => ({ + id: c.country_id, + name: c[`name_${user.language}`], + })); + } + return []; + }); + + return { organisations, countries }; +} diff --git a/client/src/stores/user.ts b/client/src/stores/user.ts index 3ab6c207..1c7c41c4 100644 --- a/client/src/stores/user.ts +++ b/client/src/stores/user.ts @@ -25,6 +25,7 @@ export type UserState = { email: string; username: string; avatar_url: string; + organisation: number | null; is_superuser: boolean; course_session_experts: string[]; loggedIn: boolean; @@ -57,6 +58,7 @@ const initialUserState: UserState = { username: "", avatar_url: "", is_superuser: false, + organisation: 0, course_session_experts: [], loggedIn: false, language: defaultLanguage, @@ -84,6 +86,19 @@ export const useUserStore = defineStore({ getFullName(): string { return `${this.first_name} ${this.last_name}`.trim(); }, + languageName(): string { + if (this.language === "de") { + return "Deutsch"; + } + if (this.language === "fr") { + return "Français"; + } + if (this.language === "it") { + return "Italiano"; + } + + return this.language; + }, }, actions: { handleLogin(username: string, password: string, next = "/") { @@ -131,5 +146,9 @@ export const useUserStore = defineStore({ this.$state.language = language; await itPost("/api/core/me/", { language }, { method: "PUT" }); }, + async setUserOrganisation(organisation: number) { + this.$state.organisation = organisation; + await itPost("/api/core/me/", { organisation }, { method: "PUT" }); + }, }, }); diff --git a/client/tailwind.css b/client/tailwind.css index f7e61624..1c7a9ee7 100644 --- a/client/tailwind.css +++ b/client/tailwind.css @@ -158,6 +158,27 @@ textarea { .btn-large-icon { @apply flex items-center px-6 py-3 text-xl font-bold; } + + .circle-numbered-list { + @apply my-8 list-outside list-decimal list-none pl-0; + counter-reset: list-counter; + } + + .circle-numbered-list li { + @apply relative mb-8 pl-10; + counter-increment: list-counter; + } + + .circle-numbered-list li:last-of-type { + @apply mb-0; + } + + .circle-numbered-list li::before { + content: counter(list-counter); + @apply absolute left-0 flex h-6 w-6 items-center justify-center rounded-full border border-gray-500 text-sm; + top: 1rem; + transform: translateY(-50%); + } } @layer utilities { diff --git a/cypress/e2e/login.cy.js b/cypress/e2e/login.cy.js index 1fdc3e2c..0f20f31a 100644 --- a/cypress/e2e/login.cy.js +++ b/cypress/e2e/login.cy.js @@ -11,36 +11,36 @@ describe("login.cy.js", () => { }); it("can login to app with username/password", () => { - cy.visit("/"); + cy.visit("/login-local"); cy.get("#username").type("test-student1@example.com"); cy.get("#password").type("test"); - cy.get('[data-cy="login-button"]').click(); + cy.get("[data-cy=\"login-button\"]").click(); cy.request("/api/core/me").its("status").should("eq", 200); - cy.get('[data-cy="dashboard-title"]').should("contain", "Dashboard"); + cy.get("[data-cy=\"dashboard-title\"]").should("contain", "Dashboard"); }); it("can login with helper function", () => { login("test-student1@example.com", "test"); cy.visit("/"); cy.request("/api/core/me").its("status").should("eq", 200); - cy.get('[data-cy="dashboard-title"]').should("contain", "Dashboard"); + cy.get("[data-cy=\"dashboard-title\"]").should("contain", "Dashboard"); }); - it("login will redirect to requestet page", () => { + it("login will redirect to requested page", () => { cy.visit("/course/test-lehrgang/learn"); cy.get("h1").should("contain", "Login"); cy.get("#username").type("test-student1@example.com"); cy.get("#password").type("test"); - cy.get('[data-cy="login-button"]').click(); + cy.get("[data-cy=\"login-button\"]").click(); - cy.get('[data-cy="learning-path-title"]').should( + cy.get("[data-cy=\"learning-path-title\"]").should( "contain", - "Test Lehrgang", + "Test Lehrgang" ); }); }); diff --git a/server/config/settings/base.py b/server/config/settings/base.py index 10fb9f13..b073746e 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -131,6 +131,7 @@ LOCAL_APPS = [ "vbv_lernwelt.importer", "vbv_lernwelt.edoniq_test", "vbv_lernwelt.course_session_group", + "vbv_lernwelt.shop", "vbv_lernwelt.learning_mentor", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps @@ -558,7 +559,12 @@ else: # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts ALLOWED_HOSTS = env.list( - "IT_DJANGO_ALLOWED_HOSTS", default=["localhost", "0.0.0.0", "127.0.0.1"] + "IT_DJANGO_ALLOWED_HOSTS", + default=[ + "localhost", + "0.0.0.0", + "127.0.0.1", + ], ) # CACHES @@ -584,38 +590,45 @@ if "django_redis.cache.RedisCache" in env("IT_DJANGO_CACHE_BACKEND", default="") }, } -# OAuth/OpenId Connect -IT_OAUTH_TENANT_ID = env.str("IT_OAUTH_TENANT_ID", default=None) +# OAuth (SSO) settings +OAUTH_SIGNUP_TENANT_ID = env("OAUTH_SIGNUP_TENANT_ID", default=None) +OAUTH_SIGNUP_PARAMS = ( + {"tenant_id": OAUTH_SIGNUP_TENANT_ID} if OAUTH_SIGNUP_TENANT_ID else {} +) -if IT_OAUTH_TENANT_ID: - IT_OAUTH_AUTHORIZE_PARAMS = {"tenant_id": IT_OAUTH_TENANT_ID} -else: - IT_OAUTH_AUTHORIZE_PARAMS = {} - -OAUTH = { - "client_name": env("IT_OAUTH_CLIENT_NAME", default="lernetz"), - "client_id": env("IT_OAUTH_CLIENT_ID", default="iterativ"), - "client_secret": env("IT_OAUTH_CLIENT_SECRET", default=""), - "authorize_params": IT_OAUTH_AUTHORIZE_PARAMS, - "access_token_params": IT_OAUTH_AUTHORIZE_PARAMS, - "api_base_url": env( - "IT_OAUTH_API_BASE_URL", - default="https://sso.test.b.lernetz.host/auth/realms/vbv/protocol/openid-connect/", - ), - "local_redirect_uri": env( - "IT_OAUTH_LOCAL_REDIRECT_URI", default="http://localhost:8000/sso/callback/" - ), - "server_metadata_url": env( - "IT_OAUTH_SERVER_METADATA_URL", - default="https://sso.test.b.lernetz.host/auth/realms/vbv/.well-known/openid-configuration", - ), - "client_kwargs": { - "scope": env("IT_OAUTH_SCOPE", default="openid email"), - "token_endpoint_auth_method": "client_secret_post", - "token_placement": "body", +AUTHLIB_OAUTH_CLIENTS = { + "signup": { + # azure + "client_id": env("OAUTH_SIGNUP_CLIENT_ID", ""), + "client_secret": env("OAUTH_SIGNUP_CLIENT_SECRET", ""), + "server_metadata_url": env("OAUTH_SIGNUP_SERVER_METADATA_URL", ""), + "access_token_params": OAUTH_SIGNUP_PARAMS, + "authorize_params": OAUTH_SIGNUP_PARAMS, + "client_kwargs": { + "scope": "openid", + "token_endpoint_auth_method": "client_secret_post", + "token_placement": "body", + }, + }, + "signin": { + # keycloak + "client_id": env("OAUTH_SIGNIN_CLIENT_ID", ""), + "client_secret": env("OAUTH_SIGNIN_CLIENT_SECRET", ""), + "server_metadata_url": env("OAUTH_SIGNIN_SERVER_METADATA_URL", ""), + "client_kwargs": { + "scope": "openid email profile", + }, }, } +OAUTH_SIGNUP_REDIRECT_URI = env( + "OAUTH_SIGNUP_REDIRECT_URI", default="http://localhost:8000/sso/login" +) + +OAUTH_SIGNIN_REDIRECT_URI = env( + "OAUTH_SIGNIN_REDIRECT_URI", default="http://localhost:8000/sso/callback" +) + GRAPHENE = { "SCHEMA": "vbv_lernwelt.core.schema.schema", "SCHEMA_OUTPUT": "../client/src/gql/schema.graphql", @@ -648,6 +661,26 @@ NOTIFICATIONS_NOTIFICATION_MODEL = "notify.Notification" # sendgrid (email notifications) SENDGRID_API_KEY = env("IT_SENDGRID_API_KEY", default="") +# Datatrans (payment) +# See https://admin.sandbox.datatrans.com/MerchSecurAdmin.jsp +DATATRANS_HMAC_KEY = env("DATATRANS_HMAC_KEY", default="") + +# See https://admin.sandbox.datatrans.com/MenuDispatch.jsp?main=1&sub=4 +# => echo -n "Username:Password" | base64 +DATATRANS_BASIC_AUTH_KEY = env("DATATRANS_BASIC_AUTH_KEY", default="") + +if APP_ENVIRONMENT.startswith("prod"): + DATATRANS_API_ENDPOINT = "https://api.datatrans.com" + DATATRANS_PAY_URL = "https://pay.datatrans.com" +else: + DATATRANS_API_ENDPOINT = "https://api.sandbox.datatrans.com" + DATATRANS_PAY_URL = "https://pay.sandbox.datatrans.com" + +# Only for debugging the webhook (locally) +DATATRANS_DEBUG_WEBHOOK_OVERWRITE = env( + "DATATRANS_DEBUG_WEBHOOK_OVERWRITE", default=None +) + # S3 BUCKET CONFIGURATION FILE_UPLOAD_STORAGE = env("FILE_UPLOAD_STORAGE", default="s3") # local | s3 diff --git a/server/config/urls.py b/server/config/urls.py index 924d072e..b85fbb1f 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -11,6 +11,8 @@ from django.views.decorators.csrf import csrf_exempt from django_ratelimit.exceptions import Ratelimited from graphene_django.views import GraphQLView +from vbv_lernwelt.api.directory import list_entities +from vbv_lernwelt.api.user import me_user_view from vbv_lernwelt.api.user import get_cockpit_type from vbv_lernwelt.assignment.views import request_assignment_completion_status from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt @@ -19,7 +21,6 @@ from vbv_lernwelt.core.views import ( check_rate_limit, cypress_reset_view, generate_web_component_icons, - me_user_view, permission_denied_view, rate_limit_exceeded_view, vue_home, @@ -99,6 +100,8 @@ urlpatterns = [ # user management path("sso/", include("vbv_lernwelt.sso.urls")), re_path(r'api/core/me/$', me_user_view, name='me_user_view'), + re_path(r'api/core/entities/$', list_entities, name='list_entities'), + re_path(r'api/core/login/$', django_view_authentication_exempt(vue_login), name='vue_login'), re_path(r'api/core/logout/$', vue_logout, name='vue_logout'), @@ -174,6 +177,9 @@ urlpatterns = [ path(r'api/core/edoniq-test/export-users-trainers/', export_students_and_trainers, name='edoniq_export_students_and_trainers'), + # shop + path("api/shop/", include("vbv_lernwelt.shop.urls")), + # importer path( r"server/importer/coursesession-trainer-import/", diff --git a/server/vbv_lernwelt/api/directory.py b/server/vbv_lernwelt/api/directory.py new file mode 100644 index 00000000..9806c77c --- /dev/null +++ b/server/vbv_lernwelt/api/directory.py @@ -0,0 +1,16 @@ +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from vbv_lernwelt.core.models import Organisation +from vbv_lernwelt.core.serializers import OrganisationSerializer +from vbv_lernwelt.shop.models import Country +from vbv_lernwelt.shop.serializers import CountrySerializer + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def list_entities(request): + organisations = OrganisationSerializer(Organisation.objects.all(), many=True).data + countries = CountrySerializer(Country.objects.all(), many=True).data + return Response({"organisations": organisations, "countries": countries}) diff --git a/server/vbv_lernwelt/api/tests/test_entities_api.py b/server/vbv_lernwelt/api/tests/test_entities_api.py new file mode 100644 index 00000000..2f65118f --- /dev/null +++ b/server/vbv_lernwelt/api/tests/test_entities_api.py @@ -0,0 +1,51 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from vbv_lernwelt.core.model_utils import add_organisations +from vbv_lernwelt.core.models import User +from vbv_lernwelt.shop.model_utils import add_countries + + +class EntitiesViewTest(APITestCase): + def setUp(self) -> None: + self.user = User.objects.create_user( + "testuser", "test@example.com", "testpassword" + ) + self.client.login(username="testuser", password="testpassword") + add_organisations() + add_countries() + + def test_list_entities(self) -> None: + # GIVEN + url = reverse("list_entities") + + # WHEN + response = self.client.get(url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + + organisations = response.data["organisations"] + + self.assertEqual( + organisations[0], + { + "organisation_id": 1, + "name_de": "andere Broker", + "name_fr": "autres Broker", + "name_it": "altre Broker", + }, + ) + + countries = response.data["countries"] + + self.assertEqual( + countries[0], + { + "country_id": 1, + "name_de": "Afghanistan", + "name_fr": "Afghanistan", + "name_it": "Afghanistan", + }, + ) diff --git a/server/vbv_lernwelt/api/tests/test_me_api.py b/server/vbv_lernwelt/api/tests/test_me_api.py new file mode 100644 index 00000000..a19fdfec --- /dev/null +++ b/server/vbv_lernwelt/api/tests/test_me_api.py @@ -0,0 +1,43 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from vbv_lernwelt.core.model_utils import add_organisations +from vbv_lernwelt.core.models import User +from vbv_lernwelt.shop.model_utils import add_countries + + +class MeUserViewTest(APITestCase): + def setUp(self) -> None: + self.user = User.objects.create_user( + "testuser", "test@example.com", "testpassword" + ) + self.client.login(username="testuser", password="testpassword") + add_organisations() + add_countries() + + def test_user_can_update_language(self) -> None: + # GIVEN + url = reverse("me_user_view") + + # WHEN + response = self.client.put(url, {"language": "it"}) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + + updated_user = User.objects.get(username="testuser") + self.assertEquals(updated_user.language, "it") + + def test_user_can_update_org(self) -> None: + # GIVEN + url = reverse("me_user_view") # replace with your actual URL name + + # WHEN + response = self.client.put(url, {"organisation": 6}) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + + updated_user = User.objects.get(username="testuser") + self.assertEquals(updated_user.organisation.organisation_id, 6) diff --git a/server/vbv_lernwelt/api/user.py b/server/vbv_lernwelt/api/user.py index 75d1acec..82d5c952 100644 --- a/server/vbv_lernwelt/api/user.py +++ b/server/vbv_lernwelt/api/user.py @@ -8,6 +8,29 @@ from vbv_lernwelt.course.models import Course, CourseSessionUser from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.learning_mentor.models import LearningMentor +from vbv_lernwelt.core.serializers import UserSerializer + + +@api_view(["GET", "PUT"]) +def me_user_view(request): + if not request.user.is_authenticated: + return Response(status=403) + + if request.method == "GET": + return Response(UserSerializer(request.user).data) + + if request.method == "PUT": + serializer = UserSerializer( + request.user, + data=request.data, + partial=True, + ) + if serializer.is_valid(): + serializer.save() + return Response(UserSerializer(request.user).data) + + return Response(status=400) + @api_view(["GET"]) @permission_classes([IsAuthenticated]) diff --git a/server/vbv_lernwelt/core/admin.py b/server/vbv_lernwelt/core/admin.py index f2a65d3b..7171b3bb 100644 --- a/server/vbv_lernwelt/core/admin.py +++ b/server/vbv_lernwelt/core/admin.py @@ -2,7 +2,7 @@ from django.contrib import admin from django.contrib.auth import admin as auth_admin, get_user_model from django.utils.translation import gettext_lazy as _ -from vbv_lernwelt.core.models import JobLog +from vbv_lernwelt.core.models import JobLog, Organisation from vbv_lernwelt.core.utils import pretty_print_json User = get_user_model() @@ -43,6 +43,7 @@ class UserAdmin(auth_admin.UserAdmin): }, ), (_("Important dates"), {"fields": ("last_login", "date_joined")}), + (_("Profile"), {"fields": ("organisation", "language")}), (_("Additional data"), {"fields": ("additional_json_data",)}), ) list_display = [ @@ -78,3 +79,13 @@ class JobLogAdmin(LogAdmin): if obj.ended: return (obj.ended - obj.started).seconds // 60 return None + + +@admin.register(Organisation) +class OrganisationAdmin(admin.ModelAdmin): + list_display = ( + "organisation_id", + "name_de", + "name_fr", + "name_it", + ) diff --git a/server/vbv_lernwelt/core/migrations/0003_organisations.py b/server/vbv_lernwelt/core/migrations/0003_organisations.py new file mode 100644 index 00000000..feb019a7 --- /dev/null +++ b/server/vbv_lernwelt/core/migrations/0003_organisations.py @@ -0,0 +1,41 @@ +import django.db.models.deletion +from django.db import migrations, models + +from vbv_lernwelt.core.model_utils import add_organisations, remove_organisations + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0002_joblog"), + ] + + operations = [ + migrations.CreateModel( + name="Organisation", + fields=[ + ( + "organisation_id", + models.IntegerField(primary_key=True, serialize=False), + ), + ("name_de", models.CharField(max_length=255)), + ("name_fr", models.CharField(max_length=255)), + ("name_it", models.CharField(max_length=255)), + ], + options={ + "verbose_name": "Organisation", + "verbose_name_plural": "Organisations", + "ordering": ["organisation_id"], + }, + ), + migrations.AddField( + model_name="user", + name="organisation", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="core.organisation", + ), + ), + migrations.RunPython(add_organisations, remove_organisations), + ] diff --git a/server/vbv_lernwelt/core/model_utils.py b/server/vbv_lernwelt/core/model_utils.py index 25f79086..ad9a1fde 100644 --- a/server/vbv_lernwelt/core/model_utils.py +++ b/server/vbv_lernwelt/core/model_utils.py @@ -32,3 +32,115 @@ def find_available_slug(requested_slug, ignore_page_id=None): number += 1 return slug + + +orgs = { + 1: {"de": "andere Broker", "fr": "autres Broker", "it": "altre Broker"}, + 2: { + "de": "andere Krankenversicherer", + "fr": "autres assureurs santé", + "it": "altre assicurazioni sanitarie", + }, + 3: { + "de": "andere Privatversicherer", + "fr": "autres Assurance privée", + "it": "altre Assicurazione privato", + }, + 4: {"de": "Allianz Suisse", "fr": "Allianz Suisse", "it": "Allianz Suisse"}, + 5: {"de": "AON", "fr": "AON", "it": "AON"}, + 6: { + "de": "AXA Winterthur", + "fr": "AXA Assurances SA", + "it": "AXA Assicurazioni SA", + }, + 7: {"de": "Baloise", "fr": "Baloise", "it": "Baloise"}, + 8: { + "de": "CAP Rechtsschutz", + "fr": "CAP Protection juridique", + "it": "CAP Protezione giuridica", + }, + 9: { + "de": "Coop Rechtsschutz", + "fr": "Coop Protection juridique", + "it": "Coop Protezione giuridica", + }, + 10: {"de": "CSS", "fr": "CSS", "it": "CSS"}, + 11: {"de": "Die Mobiliar", "fr": "La Mobilière", "it": "La Mobiliare"}, + 12: { + "de": "Emmental Versicherung", + "fr": "Emmental Assurance", + "it": "Emmental Assicurazione", + }, + 13: { + "de": "GENERALI Versicherungen", + "fr": "Generali Assurances", + "it": "Generali Assicurazioni", + }, + 14: {"de": "Groupe Mutuel", "fr": "GROUPE MUTUEL", "it": "GROUPE MUTUEL"}, + 15: {"de": "Helsana", "fr": "Helsana", "it": "Helsana"}, + 16: {"de": "Helvetia", "fr": "Helvetia", "it": "Helvetia"}, + 17: {"de": "Kessler & Co AG", "fr": "Kessler & Co AG", "it": "Kessler & Co AG"}, + 18: { + "de": "Orion Rechtsschutz Versicherung", + "fr": "Orion Protection juridique", + "it": "Orion Protezione giuridica", + }, + 19: {"de": "PAX", "fr": "PAX", "it": "PAX"}, + 20: {"de": "Sanitas", "fr": "Sanitas", "it": "Sanitas"}, + 21: {"de": "SUVA", "fr": "SUVA", "it": "SUVA"}, + 22: {"de": "Swica", "fr": "Swica", "it": "Swica"}, + 23: {"de": "Swiss Life", "fr": "Swiss Life", "it": "Swiss Life"}, + 24: {"de": "Swiss Re", "fr": "Swiss Re", "it": "Swiss Re"}, + 25: { + "de": "Visana Services AG", + "fr": "Visana Services SA", + "it": "Visana Services SA", + }, + 26: { + "de": "VZ VermögensZentrum AG", + "fr": "VZ VermögensZentrum AG", + "it": "VZ VermögensZentrum AG", + }, + 27: { + "de": "Würth Financial Services AG", + "fr": "Würth Financial Services SA", + "it": "Würth Financial Services SA", + }, + 28: {"de": "Zürich", "fr": "Zurich", "it": "Zurigo"}, + 29: {"de": "VBV", "fr": "AFA", "it": "AFA"}, + 30: {"de": "Vaudoise", "fr": "Vaudoise", "it": "Vaudoise"}, + 31: { + "de": "Keine Firmenzugehörigkeit", + "fr": "Pas d'appartenance à une entreprise", + "it": "Nessuna affiliazione aziendale", + }, +} + + +def add_organisations(apps=None, schema_editor=None): + if apps is None: + # pylint: disable=import-outside-toplevel + from vbv_lernwelt.core.models import Organisation + else: + Organisation = apps.get_model("core", "Organisation") + + for org_id, org_data in orgs.items(): + Organisation.objects.get_or_create( + organisation_id=org_id, + name_de=org_data["de"], + name_fr=org_data["fr"], + name_it=org_data["it"], + ) + + +def remove_organisations(apps=None, schema_editor=None): + if apps is None: + # pylint: disable=import-outside-toplevel + from vbv_lernwelt.core.models import Organisation + else: + Organisation = apps.get_model("core", "Organisation") + + for org_id in orgs.keys(): + Organisation.objects.filter( + organisation_id=org_id, + ).delete() diff --git a/server/vbv_lernwelt/core/models.py b/server/vbv_lernwelt/core/models.py index 524a2133..4895d2d3 100644 --- a/server/vbv_lernwelt/core/models.py +++ b/server/vbv_lernwelt/core/models.py @@ -5,6 +5,21 @@ from django.db import models from django.db.models import JSONField +class Organisation(models.Model): + organisation_id = models.IntegerField(primary_key=True) + name_de = models.CharField(max_length=255) + name_fr = models.CharField(max_length=255) + name_it = models.CharField(max_length=255) + + def __str__(self): + return f"{self.name_de} ({self.organisation_id})" + + class Meta: + verbose_name = "Organisation" + verbose_name_plural = "Organisations" + ordering = ["organisation_id"] + + class User(AbstractUser): """ Default custom user model for VBV Lernwelt. @@ -29,6 +44,10 @@ class User(AbstractUser): additional_json_data = JSONField(default=dict, blank=True) language = models.CharField(max_length=2, choices=LANGUAGE_CHOICES, default="de") + organisation = models.ForeignKey( + Organisation, on_delete=models.SET_NULL, null=True, blank=True + ) + class SecurityRequestResponseLog(models.Model): label = models.CharField(max_length=255, blank=True, default="") diff --git a/server/vbv_lernwelt/core/serializers.py b/server/vbv_lernwelt/core/serializers.py index 523d70ec..31483733 100644 --- a/server/vbv_lernwelt/core/serializers.py +++ b/server/vbv_lernwelt/core/serializers.py @@ -3,7 +3,7 @@ from typing import List from rest_framework import serializers from rest_framework.renderers import JSONRenderer -from vbv_lernwelt.core.models import User +from vbv_lernwelt.core.models import Organisation, User from vbv_lernwelt.course.models import CourseSessionUser from vbv_lernwelt.course_session_group.models import CourseSessionGroup @@ -25,6 +25,7 @@ class UserSerializer(serializers.ModelSerializer): "email", "username", "avatar_url", + "organisation", "is_superuser", "course_session_experts", "language", @@ -52,3 +53,9 @@ class UserSerializer(serializers.ModelSerializer): ) return [str(_id) for _id in (supervisor_in_session_ids | expert_in_session_ids)] + + +class OrganisationSerializer(serializers.ModelSerializer): + class Meta: + model = Organisation + fields = "__all__" diff --git a/server/vbv_lernwelt/core/views.py b/server/vbv_lernwelt/core/views.py index 64bb11c4..aeb6825e 100644 --- a/server/vbv_lernwelt/core/views.py +++ b/server/vbv_lernwelt/core/views.py @@ -7,7 +7,12 @@ import structlog from django.conf import settings from django.contrib.auth import authenticate, login, logout from django.core.management import call_command -from django.http import HttpResponse, HttpResponseRedirect, JsonResponse +from django.http import ( + HttpResponse, + HttpResponseRedirect, + JsonResponse, + StreamingHttpResponse, +) from django.shortcuts import render from django.template import loader from django.views.decorators.csrf import ensure_csrf_cookie @@ -31,12 +36,21 @@ logger = structlog.get_logger(__name__) @ensure_csrf_cookie def vue_home(request, *args): if settings.IT_SERVE_VUE: + from gunicorn.util import is_hoppish + try: - res = requests.get(f"{settings.IT_SERVE_VUE_URL}{request.get_full_path()}") - content = res.text - headers = res.headers - content_type = headers.get("content-type", "text/html") - return HttpResponse(content, content_type=content_type) + path = request.get_full_path() + res = requests.get(f"{settings.IT_SERVE_VUE_URL}{path}", stream=True) + response = StreamingHttpResponse( + streaming_content=(chunk for chunk in res.iter_content(4096)), + content_type=res.headers.get("Content-Type", "text/html"), + status=res.status_code, + ) + for name, value in res.headers.items(): + if not is_hoppish(name): + response[name] = value + + return response except Exception as e: return HttpResponse( f"Can not connect to vue dev server at {settings.IT_SERVE_VUE_URL}: {e}" @@ -76,27 +90,6 @@ def vue_login(request): ) -@api_view(["GET", "PUT"]) -def me_user_view(request): - if not request.user.is_authenticated: - return Response(status=403) - - if request.method == "GET": - return Response(UserSerializer(request.user).data) - - if request.method == "PUT": - serializer = UserSerializer( - request.user, - data={"language": request.data.get("language", "de")}, - partial=True, - ) - if serializer.is_valid(): - serializer.save() - return Response(UserSerializer(request.user).data) - - return Response(status=400) - - @api_view(["POST"]) def vue_logout(request): logout(request) diff --git a/server/vbv_lernwelt/course/creators/test_utils.py b/server/vbv_lernwelt/course/creators/test_utils.py index 7fc1fdbe..3a396754 100644 --- a/server/vbv_lernwelt/course/creators/test_utils.py +++ b/server/vbv_lernwelt/course/creators/test_utils.py @@ -58,8 +58,8 @@ from vbv_lernwelt.learnpath.tests.learning_path_factories import ( ) -def create_course(title: str) -> Tuple[Course, CoursePage]: - course = Course.objects.create(title=title, category_name="Handlungsfeld") +def create_course(title: str, _id=None) -> Tuple[Course, CoursePage]: + course = Course.objects.create(id=_id, title=title, category_name="Handlungsfeld") course_page = CoursePageFactory( title="Test Lehrgang", diff --git a/server/vbv_lernwelt/course_session/utils.py b/server/vbv_lernwelt/course_session/utils.py new file mode 100644 index 00000000..b37b1831 --- /dev/null +++ b/server/vbv_lernwelt/course_session/utils.py @@ -0,0 +1,19 @@ +from vbv_lernwelt.core.models import User +from vbv_lernwelt.course.consts import ( + COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID, +) +from vbv_lernwelt.course.models import CourseSession + + +def has_course_session_user_vv(user: User) -> bool: + vv_course_ids = [ + COURSE_VERSICHERUNGSVERMITTLERIN_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID, + ] + + return CourseSession.objects.filter( + course__id__in=vv_course_ids, coursesessionuser__user=user + ).exists() diff --git a/server/vbv_lernwelt/importer/services.py b/server/vbv_lernwelt/importer/services.py index be64194a..e62f1a04 100644 --- a/server/vbv_lernwelt/importer/services.py +++ b/server/vbv_lernwelt/importer/services.py @@ -216,7 +216,7 @@ def create_or_update_user( sso_id: str = None, contract_number: str = "", date_of_birth: str = "", -): +) -> User: logger.debug( "create_or_update_user", email=email, diff --git a/server/vbv_lernwelt/notify/email/email_services.py b/server/vbv_lernwelt/notify/email/email_services.py index 6bc0c42a..6281dcf1 100644 --- a/server/vbv_lernwelt/notify/email/email_services.py +++ b/server/vbv_lernwelt/notify/email/email_services.py @@ -62,6 +62,13 @@ class EmailTemplate(Enum): # VBV - Neues Feedback für Circle NEW_FEEDBACK = {"de": "d-40fb94d5149949e7b8e7ddfcf0fcfdde"} + # Versicherungsvermittler (after buying a course) + WELCOME_MAIL_VV = { + "de": "d-308a72c779b74c8487cdec03c772ad13", + "fr": "d-1a0958c7798c4dd18f730491e920eab5", + "it": "d-0882ec9c92f64312b9f358481a943c9a", + } + # VBV - Lernbegleitung Einladung LEARNING_MENTOR_INVITATION = { "de": "d-8c862afde62748b6b8410887eeee89d8", diff --git a/server/vbv_lernwelt/shop/README.md b/server/vbv_lernwelt/shop/README.md new file mode 100644 index 00000000..008a056b --- /dev/null +++ b/server/vbv_lernwelt/shop/README.md @@ -0,0 +1,76 @@ +# Setup steps for Production + +## Shop Product + +In the Django shop app, create new products that should be available in the shop: + +- `vv-de` Price 32430 (324_3- -> 324.30 CHF), base 300 CHF + 8.1% MWSt., name & description can be anything. + - ONLY if `COURSE_VERSICHERUNGSVERMITTLERIN_ID` exists! +- `vv-fr` Price 32430 (324_3- -> 324.30 CHF), base 300 CHF + 8.1% MWSt., name & description can be anything. + - ONLY if `COURSE_VERSICHERUNGSVERMITTLERIN_ID_FR` exists! +- `vv-it` Price 32430 (324_3- -> 324.30 CHF), base 300 CHF + 8.1% MWSt., name & description can be anything. + - ONLY if `COURSE_VERSICHERUNGSVERMITTLERIN_ID_IT` exists! + +## Datatrans (Payment Provider) + +- Set `DATATRANS_BASIC_AUTH_KEY`: + - https://admin.sandbox.datatrans.com/MenuDispatch.jsp?main=1&sub=4 + - `echo -n "{merchantid}:{password}" | base64` + +- Set `DATATRANS_HMAC_KEY`: + - https://admin.sandbox.datatrans.com/MerchSecurAdmin.jsp + +For Production: + +1. Coordinate with datatrans to get production account. -> TBD! +2. Set `DATATRANS_BASIC_AUTH_KEY` and `DATATRANS_HMAC_KEY` to the production values (see above). + +## OAUTH + +For Production: Make sure that the following env vars are set: + +### Azure B2C + +- Set `OAUTH_SIGNUP_CLIENT_ID` +- Set `OAUTH_SIGNUP_CLIENT_SECRET` +- Set `OAUTH_SIGNUP_SERVER_METADATA_URL` (.well-known/openid-configuration) +- Set `OAUTH_SIGNUP_TENANT_ID` +- Set `OAUTH_SIGNUP_REDIRECT_URI` (`.../sso/login` e.g. `https://myvbv-stage.iterativ.ch/sso/login`) + +### Keycloak + +- Set `OAUTH_SIGNIN_CLIENT_ID` +- Set `OAUTH_SIGNIN_CLIENT_SECRET` +- Set `OAUTH_SIGNIN_SERVER_METADATA_URL` (.well-known/openid-configuration) +- Set `OAUTH_SIGNIN_REDIRECT_URI` (`.../sso/callback` e.g. `https://myvbv-stage.iterativ.ch/sso/callback`) + +### Caprover (VITEx) + +- Set `VITE_OAUTH_API_BASE_URL` in `caprover_deploy.sh` for `prod` environment. + - `OAUTH_SIGNIN_SERVER_METADATA_URL` should help to find the correct value. + - Should be the SSO Prod one from Lernnetz. -> TBD! + +### send_vv_welcome_email() + +- Due to lack of access to Sendgrid, never tested actually sending the email. + +## Testing Payment Flow + +- To get user into state for testing (e.g. test-student1@example.com so that he can buy the course): + - Remove all existing course session users for the user. + - Remove all existing checkout information for the user. + +### Cleanup + +After everything runs fine, we should be able to remove the following deprecated env vars: + +1. `IT_OAUTH_TENANT_ID` +2. `IT_OAUTH_CLIENT_NAME` +3. `IT_OAUTH_CLIENT_ID` +4. `IT_OAUTH_CLIENT_SECRET` +5. `IT_OAUTH_API_BASE_URL` +6. `IT_OAUTH_LOCAL_REDIRECT_URI` +7. `IT_OAUTH_SERVER_METADATA_URL` +8. `IT_OAUTH_SCOPE` + + diff --git a/server/vbv_lernwelt/shop/__init__.py b/server/vbv_lernwelt/shop/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/shop/admin.py b/server/vbv_lernwelt/shop/admin.py new file mode 100644 index 00000000..5432cc18 --- /dev/null +++ b/server/vbv_lernwelt/shop/admin.py @@ -0,0 +1,55 @@ +from django.contrib import admin + +from vbv_lernwelt.shop.models import CheckoutInformation, Country, Product +from vbv_lernwelt.shop.services import get_transaction_state + + +@admin.action(description="ABACUS: Create invoices") +def generate_invoice(modeladmin, request, queryset): + pass + + +@admin.action(description="DATATRANS: Sync transaction states") +def sync_transaction_state(modeladmin, request, queryset): + for checkout in queryset: + state = get_transaction_state(transaction_id=checkout.transaction_id) + checkout.state = state.value + checkout.save( + update_fields=[ + "state", + ] + ) + + +@admin.register(CheckoutInformation) +class CheckoutInformationAdmin(admin.ModelAdmin): + list_display = ( + "product_sku", + "user", + "product_name", + "product_price", + "updated_at", + "state", + "invoice_transmitted_at", + ) + actions = [generate_invoice, sync_transaction_state] + + +@admin.register(Country) +class CountryAdmin(admin.ModelAdmin): + list_display = ( + "country_id", + "name_de", + "name_fr", + "name_it", + ) + + +@admin.register(Product) +class ProductAdmin(admin.ModelAdmin): + list_display = ( + "sku", + "name", + "price", + "description", + ) diff --git a/server/vbv_lernwelt/shop/apps.py b/server/vbv_lernwelt/shop/apps.py new file mode 100644 index 00000000..c270114e --- /dev/null +++ b/server/vbv_lernwelt/shop/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ShopConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "vbv_lernwelt.shop" diff --git a/server/vbv_lernwelt/shop/const.py b/server/vbv_lernwelt/shop/const.py new file mode 100644 index 00000000..01eb648b --- /dev/null +++ b/server/vbv_lernwelt/shop/const.py @@ -0,0 +1,4 @@ +# available products for VV +VV_DE_PRODUCT_SKU = "vv-de" +VV_FR_PRODUCT_SKU = "vv-fr" +VV_IT_PRODUCT_SKU = "vv-it" diff --git a/server/vbv_lernwelt/shop/invoice/__init__.py b/server/vbv_lernwelt/shop/invoice/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/shop/invoice/abacus.py b/server/vbv_lernwelt/shop/invoice/abacus.py new file mode 100644 index 00000000..a92b1056 --- /dev/null +++ b/server/vbv_lernwelt/shop/invoice/abacus.py @@ -0,0 +1,146 @@ +import datetime +from typing import List +from uuid import uuid4 +from xml.dom import minidom +from xml.etree.ElementTree import Element, SubElement, tostring + +from vbv_lernwelt.shop.invoice.creator import InvoiceCreator, Item +from vbv_lernwelt.shop.invoice.repositories import InvoiceRepository +from vbv_lernwelt.shop.models import CheckoutInformation + + +class AbacusInvoiceCreator(InvoiceCreator): + def __init__(self, repository: InvoiceRepository): + self.repository = repository + + def create_invoice( + self, + checkout_information: CheckoutInformation, + filename: str = None, + ): + customer_number = checkout_information.transaction_id + order_date = checkout_information.created_at.date() + reference_purchase_order = str(checkout_information.id) + unic_id = checkout_information.transaction_id + + items = [ + Item( + product_number=checkout_information.product_sku, + quantity=1, + description=checkout_information.product_description, + ) + ] + + invoice = self.invoice_xml( + customer_number, + order_date, + reference_purchase_order, + unic_id, + items, + ) + + if filename is None: + filename = f"vbv-vv-{uuid4().hex}.xml" + + self.repository.upload_invoice(invoice, filename) + + @staticmethod + def invoice_xml( + customer_number: str, + order_date: datetime.date, + reference_purchase_order: str, + unic_id: str, + items: List[Item], + ) -> str: + container = Element("AbaConnectContainer") + task = SubElement(container, "Task") + parameter = SubElement(task, "Parameter") + SubElement(parameter, "Application").text = "ORDE" + SubElement(parameter, "Id").text = "Verkaufsauftrag" + SubElement(parameter, "MapId").text = "AbaDefault" + SubElement(parameter, "Version").text = "2022.00" + + transaction = SubElement(task, "Transaction") + sales_order_header = SubElement(transaction, "SalesOrderHeader", mode="SAVE") + sales_order_header_fields = SubElement( + sales_order_header, "SalesOrderHeaderFields", mode="SAVE" + ) + + SubElement(sales_order_header_fields, "CustomerNumber").text = customer_number + SubElement( + sales_order_header_fields, "PurchaseOrderDate" + ).text = order_date.isoformat() + SubElement( + sales_order_header_fields, "DeliveryDate" + ).text = order_date.isoformat() + SubElement( + sales_order_header_fields, "ReferencePurchaseOrder" + ).text = reference_purchase_order + SubElement(sales_order_header_fields, "UnicId").text = unic_id + + for index, item in enumerate(items, start=1): + item_element = SubElement(sales_order_header, "Item", mode="SAVE") + item_fields = SubElement(item_element, "ItemFields", mode="SAVE") + SubElement(item_fields, "ItemNumber").text = str(index) + SubElement(item_fields, "ProductNumber").text = item.product_number + SubElement(item_fields, "QuantityOrdered").text = str(item.quantity) + + item_text = SubElement(item_element, "ItemText", mode="SAVE") + item_text_fields = SubElement(item_text, "ItemTextFields", mode="SAVE") + SubElement(item_text_fields, "Text").text = item.description + + return AbacusInvoiceCreator.create_xml_string(container) + + @staticmethod + def customer_xml( + customer_number: str, + name: str, + first_name: str, + address_text: str, + street: str, + house_number: str, + zip_code: str, + city: str, + country: str, + language: str, + email: str, + ): + container = Element("AbaConnectContainer") + task = SubElement(container, "Task") + + parameter = SubElement(task, "Parameter") + SubElement(parameter, "Application").text = "DEBI" + SubElement(parameter, "ID").text = "Kunden" + SubElement(parameter, "MapID").text = "AbaDefault" + SubElement(parameter, "Version").text = "2022.00" + + transaction = SubElement(task, "Transaction") + customer_element = SubElement(transaction, "Customer", mode="SAVE") + + SubElement(customer_element, "CustomerNumber").text = customer_number + SubElement(customer_element, "DefaultCurrency").text = "CHF" + SubElement(customer_element, "PaymentTermNumber").text = "1" + SubElement(customer_element, "ReminderProcedure").text = "NORM" + + address_data = SubElement(customer_element, "AddressData", mode="SAVE") + SubElement(address_data, "AddressNumber").text = customer_number + SubElement(address_data, "Name").text = name + SubElement(address_data, "FirstName").text = first_name + SubElement(address_data, "Text").text = address_text + SubElement(address_data, "Street").text = street + SubElement(address_data, "HouseNumber").text = house_number + SubElement(address_data, "ZIP").text = zip_code + SubElement(address_data, "City").text = city + SubElement(address_data, "Country").text = country + SubElement(address_data, "Language").text = language + SubElement(address_data, "Email").text = email + + return AbacusInvoiceCreator.create_xml_string(container) + + @staticmethod + def create_xml_string(container: Element, encoding: str = "UTF-8") -> str: + xml_bytes = tostring(container, encoding) + xml_pretty_str = minidom.parseString(xml_bytes).toprettyxml( + indent=" ", encoding=encoding + ) + return xml_pretty_str.decode(encoding) diff --git a/server/vbv_lernwelt/shop/invoice/creator.py b/server/vbv_lernwelt/shop/invoice/creator.py new file mode 100644 index 00000000..85c08475 --- /dev/null +++ b/server/vbv_lernwelt/shop/invoice/creator.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass + +from vbv_lernwelt.shop.models import CheckoutInformation + + +@dataclass +class Item: + product_number: str + quantity: int + description: str + + +class InvoiceCreator(ABC): + @abstractmethod + def create_invoice( + self, + checkout_information: CheckoutInformation, + filename: str = None, + ): + pass diff --git a/server/vbv_lernwelt/shop/invoice/repositories.py b/server/vbv_lernwelt/shop/invoice/repositories.py new file mode 100644 index 00000000..df231324 --- /dev/null +++ b/server/vbv_lernwelt/shop/invoice/repositories.py @@ -0,0 +1,44 @@ +from abc import ABC, abstractmethod + +import structlog + +logger = structlog.get_logger(__name__) + + +class InvoiceRepository(ABC): + @abstractmethod + def upload_invoice(self, invoice: str, filename: str): + pass + + +class SFTPInvoiceRepository(InvoiceRepository): + def __init__(self, hostname: str, username: str, password: str, port: int = 22): + self.hostname = hostname + self.username = username + self.password = password + self.port = port + + def upload_invoice(self, invoice: str, filename: str) -> None: + from io import BytesIO + + from paramiko import AutoAddPolicy, SSHClient + + invoice_io = BytesIO(invoice.encode("utf-8")) + ssh_client = SSHClient() + + try: + ssh_client.set_missing_host_key_policy(AutoAddPolicy()) + ssh_client.connect( + self.hostname, + port=self.port, + username=self.username, + password=self.password, + ) + + with ssh_client.open_sftp() as sftp_client: + sftp_client.putfo(invoice_io, f"uploads/{filename}") + + except Exception as e: + logger.error("Could not upload invoice", exc_info=e) + finally: + ssh_client.close() diff --git a/server/vbv_lernwelt/shop/migrations/0001_initial.py b/server/vbv_lernwelt/shop/migrations/0001_initial.py new file mode 100644 index 00000000..f0be72b0 --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0001_initial.py @@ -0,0 +1,142 @@ +# Generated by Django 3.2.20 on 2023-11-14 10:46 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Product", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("price", models.IntegerField()), + ( + "sku", + models.CharField( + choices=[("1", "VV")], max_length=255, unique=True + ), + ), + ("name", models.CharField(max_length=255)), + ("description", models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name="CheckoutInformation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "product_sku", + models.CharField(choices=[("1", "VV")], max_length=255), + ), + ("product_price", models.IntegerField()), + ("product_name", models.CharField(max_length=255)), + ("product_description", models.CharField(max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "state", + models.CharField( + choices=[ + ("initialized", "initialized"), + ("settled", "settled"), + ("canceled", "canceled"), + ("failed", "failed"), + ], + max_length=255, + ), + ), + ("invoice_transmitted_at", models.DateTimeField(blank=True, null=True)), + ("transaction_id", models.CharField(max_length=255)), + ("first_name", models.CharField(max_length=255)), + ("last_name", models.CharField(max_length=255)), + ("street_address", models.CharField(max_length=255)), + ("street_number_address", models.CharField(max_length=255)), + ("postal_code", models.CharField(max_length=255)), + ("city", models.CharField(max_length=255)), + ("country", models.CharField(max_length=255)), + ("company_name", models.CharField(blank=True, max_length=255)), + ( + "company_street_address", + models.CharField(blank=True, max_length=255), + ), + ( + "company_street_number_address", + models.CharField(blank=True, max_length=255), + ), + ("company_postal_code", models.CharField(blank=True, max_length=255)), + ("company_city", models.CharField(blank=True, max_length=255)), + ("company_country", models.CharField(blank=True, max_length=255)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="BillingAddress", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("first_name", models.CharField(blank=True, max_length=255)), + ("last_name", models.CharField(blank=True, max_length=255)), + ("street_address", models.CharField(blank=True, max_length=255)), + ("street_number_address", models.CharField(blank=True, max_length=255)), + ("postal_code", models.CharField(blank=True, max_length=255)), + ("city", models.CharField(blank=True, max_length=255)), + ("country", models.CharField(blank=True, max_length=255)), + ("company_name", models.CharField(blank=True, max_length=255)), + ( + "company_street_address", + models.CharField(blank=True, max_length=255), + ), + ( + "company_street_number_address", + models.CharField(blank=True, max_length=255), + ), + ("company_postal_code", models.CharField(blank=True, max_length=255)), + ("company_city", models.CharField(blank=True, max_length=255)), + ("company_country", models.CharField(blank=True, max_length=255)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/server/vbv_lernwelt/shop/migrations/0002_auto_20231114_1926.py b/server/vbv_lernwelt/shop/migrations/0002_auto_20231114_1926.py new file mode 100644 index 00000000..34c4a0de --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0002_auto_20231114_1926.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.20 on 2023-11-14 18:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("shop", "0001_initial"), + ] + + operations = [ + migrations.RenameField( + model_name="billingaddress", + old_name="company_street_address", + new_name="company_street", + ), + migrations.RenameField( + model_name="billingaddress", + old_name="company_street_number_address", + new_name="company_street_number", + ), + migrations.RenameField( + model_name="billingaddress", + old_name="street_address", + new_name="street", + ), + migrations.RenameField( + model_name="billingaddress", + old_name="street_number_address", + new_name="street_number", + ), + ] diff --git a/server/vbv_lernwelt/shop/migrations/0003_auto_20231114_2036.py b/server/vbv_lernwelt/shop/migrations/0003_auto_20231114_2036.py new file mode 100644 index 00000000..2757d89b --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0003_auto_20231114_2036.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.20 on 2023-11-14 19:36 + +from django.db import migrations, models + +from vbv_lernwelt.shop.model_utils import add_countries, remove_countries + + +class Migration(migrations.Migration): + dependencies = [ + ("shop", "0002_auto_20231114_1926"), + ] + + operations = [ + migrations.CreateModel( + name="Country", + fields=[ + ("country_id", models.IntegerField(primary_key=True, serialize=False)), + ("name_de", models.CharField(max_length=255)), + ("name_fr", models.CharField(max_length=255)), + ("name_it", models.CharField(max_length=255)), + ], + options={ + "verbose_name": "Country", + "verbose_name_plural": "Countries", + "ordering": ["country_id"], + }, + ), + migrations.RemoveField( + model_name="product", + name="id", + ), + migrations.AlterField( + model_name="checkoutinformation", + name="product_sku", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="product", + name="sku", + field=models.CharField(max_length=255, primary_key=True, serialize=False), + ), + migrations.RunPython(add_countries, remove_countries), + ] diff --git a/server/vbv_lernwelt/shop/migrations/0004_auto_20231116_1336.py b/server/vbv_lernwelt/shop/migrations/0004_auto_20231116_1336.py new file mode 100644 index 00000000..e04553aa --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0004_auto_20231116_1336.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.20 on 2023-11-16 12:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("shop", "0003_auto_20231114_2036"), + ] + + operations = [ + migrations.RenameField( + model_name="checkoutinformation", + old_name="street_address", + new_name="street", + ), + migrations.RenameField( + model_name="checkoutinformation", + old_name="street_number_address", + new_name="street_number", + ), + ] diff --git a/server/vbv_lernwelt/shop/migrations/0005_auto_20231116_1338.py b/server/vbv_lernwelt/shop/migrations/0005_auto_20231116_1338.py new file mode 100644 index 00000000..f7955926 --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0005_auto_20231116_1338.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.20 on 2023-11-16 12:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("shop", "0004_auto_20231116_1336"), + ] + + operations = [ + migrations.RenameField( + model_name="checkoutinformation", + old_name="company_street_address", + new_name="company_street", + ), + migrations.RenameField( + model_name="checkoutinformation", + old_name="company_street_number_address", + new_name="company_street_number", + ), + ] diff --git a/server/vbv_lernwelt/shop/migrations/0006_alter_checkoutinformation_state.py b/server/vbv_lernwelt/shop/migrations/0006_alter_checkoutinformation_state.py new file mode 100644 index 00000000..88d8d2a6 --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0006_alter_checkoutinformation_state.py @@ -0,0 +1,26 @@ +# Generated by Django 3.2.20 on 2023-11-16 17:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("shop", "0005_auto_20231116_1338"), + ] + + operations = [ + migrations.AlterField( + model_name="checkoutinformation", + name="state", + field=models.CharField( + choices=[ + ("initialized", "Initialized"), + ("settled", "Settled"), + ("transmitted", "Transmitted"), + ("canceled", "Canceled"), + ("failed", "Failed"), + ], + max_length=50, + ), + ), + ] diff --git a/server/vbv_lernwelt/shop/migrations/0007_checkoutinformation_webhook_history.py b/server/vbv_lernwelt/shop/migrations/0007_checkoutinformation_webhook_history.py new file mode 100644 index 00000000..b205cacc --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0007_checkoutinformation_webhook_history.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.20 on 2023-11-16 23:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("shop", "0006_alter_checkoutinformation_state"), + ] + + operations = [ + migrations.AddField( + model_name="checkoutinformation", + name="webhook_history", + field=models.JSONField(default=list), + ), + ] diff --git a/server/vbv_lernwelt/shop/migrations/0008_auto_20231117_0905.py b/server/vbv_lernwelt/shop/migrations/0008_auto_20231117_0905.py new file mode 100644 index 00000000..a0178ced --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0008_auto_20231117_0905.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.20 on 2023-11-17 08:05 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("shop", "0007_checkoutinformation_webhook_history"), + ] + + operations = [ + migrations.RemoveField( + model_name="billingaddress", + name="id", + ), + migrations.AlterField( + model_name="billingaddress", + name="user", + field=models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/server/vbv_lernwelt/shop/migrations/0009_alter_checkoutinformation_product_price.py b/server/vbv_lernwelt/shop/migrations/0009_alter_checkoutinformation_product_price.py new file mode 100644 index 00000000..01f1b3a8 --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0009_alter_checkoutinformation_product_price.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.20 on 2023-11-17 13:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("shop", "0008_auto_20231117_0905"), + ] + + operations = [ + migrations.AlterField( + model_name="checkoutinformation", + name="product_price", + field=models.IntegerField( + help_text="The total price of the product in centimes -> 1000 = 10.00 CHF" + ), + ), + ] diff --git a/server/vbv_lernwelt/shop/migrations/0010_alter_checkoutinformation_state.py b/server/vbv_lernwelt/shop/migrations/0010_alter_checkoutinformation_state.py new file mode 100644 index 00000000..bbaf9b47 --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0010_alter_checkoutinformation_state.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.20 on 2023-11-26 19:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("shop", "0009_alter_checkoutinformation_product_price"), + ] + + operations = [ + migrations.AlterField( + model_name="checkoutinformation", + name="state", + field=models.CharField( + choices=[ + ("initialized", "Initialized"), + ("paid", "Paid"), + ("canceled", "Canceled"), + ("failed", "Failed"), + ], + max_length=50, + ), + ), + ] diff --git a/server/vbv_lernwelt/shop/migrations/0011_alter_checkoutinformation_state.py b/server/vbv_lernwelt/shop/migrations/0011_alter_checkoutinformation_state.py new file mode 100644 index 00000000..bb2dedd9 --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0011_alter_checkoutinformation_state.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.20 on 2023-12-05 13:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("shop", "0010_alter_checkoutinformation_state"), + ] + + operations = [ + migrations.AlterField( + model_name="checkoutinformation", + name="state", + field=models.CharField( + choices=[ + ("ongoing", "Ongoing"), + ("paid", "Paid"), + ("canceled", "Canceled"), + ("failed", "Failed"), + ], + max_length=50, + ), + ), + ] diff --git a/server/vbv_lernwelt/shop/migrations/__init__.py b/server/vbv_lernwelt/shop/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/shop/model_utils.py b/server/vbv_lernwelt/shop/model_utils.py new file mode 100644 index 00000000..ec959b86 --- /dev/null +++ b/server/vbv_lernwelt/shop/model_utils.py @@ -0,0 +1,427 @@ +countries = { + 1: {"de": "Afghanistan", "fr": "Afghanistan", "it": "Afghanistan"}, + 2: {"de": "Albanien", "fr": "Albanie", "it": "Albania"}, + 3: {"de": "Algerien", "fr": "Algérie", "it": "Algeria"}, + 5: {"de": "Andorra", "fr": "Andorra", "it": "Andorra"}, + 6: {"de": "Angola", "fr": "Angola", "it": "Angola"}, + 9: { + "de": "Antigua und Barbuda", + "fr": "Antigua et Barbuda", + "it": "Antigua e Barbuda", + }, + 10: {"de": "Argentinien", "fr": "Argentine", "it": "Argentina"}, + 11: {"de": "Armenien", "fr": "Armenia", "it": "Armenia"}, + 13: {"de": "Australien", "fr": "Australie", "it": "Australia"}, + 14: {"de": "Österreich", "fr": "Autriche", "it": "Austria"}, + 15: {"de": "Aserbaidschan", "fr": "Azerbaïdjan", "it": "Azerbaijan"}, + 16: {"de": "Bahamas", "fr": "Bahamas", "it": "Bahamas"}, + 17: {"de": "Bahrain", "fr": "Bahrain", "it": "Bahrain"}, + 18: {"de": "Bangladesh", "fr": "Bangladesh", "it": "Bangladesh"}, + 19: {"de": "Barbados", "fr": "Barbados", "it": "Barbados"}, + 20: {"de": "Belarus", "fr": "Belarus", "it": "Belarus"}, + 21: {"de": "Belgien", "fr": "Belgique", "it": "Belgio"}, + 22: {"de": "Belize", "fr": "Belize", "it": "Belize"}, + 23: {"de": "Benin", "fr": "Benin", "it": "Benin"}, + 25: {"de": "Bhutan", "fr": "Bhutan", "it": "Bhutan"}, + 26: {"de": "Bolivien", "fr": "Bolivia", "it": "Bolivia"}, + 27: { + "de": "Bosnien und Herzegowina", + "fr": "Bosnia et Herzegowina", + "it": "Bosnia e Herzegovina", + }, + 28: {"de": "Botswana", "fr": "Botswana", "it": "Botswana"}, + 30: {"de": "Brasilien", "fr": "Brésil", "it": "Brasile"}, + 32: {"de": "Brunei", "fr": "Brunei", "it": "Brunei"}, + 33: {"de": "Bulgarien", "fr": "Bulgarie", "it": "Bulgaria"}, + 34: {"de": "Burkina Faso", "fr": "Burkina Faso", "it": "Burkina Faso"}, + 35: {"de": "Burundi", "fr": "Burundi", "it": "Burundi"}, + 36: {"de": "Kambodscha", "fr": "Cambodia", "it": "Cambogia"}, + 37: {"de": "Kamerun", "fr": "Cameroon", "it": "Camerun"}, + 38: {"de": "Kanada", "fr": "Canada", "it": "Canada"}, + 39: {"de": "Kap Verde", "fr": "Cap Vert", "it": "Capo Verde"}, + 41: { + "de": "Zentralafrikanische Republik", + "fr": "Centrafricaine (République)", + "it": "Repubblica Centrafricana", + }, + 42: {"de": "Tschad", "fr": "Tchad", "it": "Ciad"}, + 43: {"de": "Chile", "fr": "Chile", "it": "Cile"}, + 44: {"de": "Volksrepublik China", "fr": "Chine (Rép. pop.)", "it": "Cina"}, + 47: {"de": "Kolumbien", "fr": "Colombia", "it": "Colombia"}, + 48: {"de": "Komoren", "fr": "Comoros", "it": "Comoros"}, + 49: { + "de": "Kongo, Republik", + "fr": "Congo, Republic of the", + "it": "Congo, Repubblica del", + }, + 50: { + "de": "Kongo, Demokratische Republik", + "fr": "Congo, The Democratic Republic of the", + "it": "Congo, Repubblica Democratica del", + }, + 51: {"de": "Grenada", "fr": "Grenade", "it": "Grenada"}, + 52: {"de": "Costa Rica", "fr": "Costa Rica", "it": "Costa Rica"}, + 53: {"de": "Elfenbeinküste", "fr": "Côte d´Ivoire", "it": "Costa d´Avorio"}, + 54: {"de": "Kroatien", "fr": "Croatia", "it": "Croazia"}, + 55: {"de": "Kuba", "fr": "Cuba", "it": "Cuba"}, + 56: {"de": "Zypern", "fr": "Cyprus", "it": "Cipro"}, + 57: { + "de": "Tschechische Republik", + "fr": "Czech Rebublic", + "it": "Repubblica Ceca", + }, + 58: {"de": "Dänemark", "fr": "Danemark", "it": "Danimarca"}, + 59: {"de": "Dschibuti", "fr": "Djibouti", "it": "Gibuti"}, + 60: {"de": "Dominica", "fr": "Dominique", "it": "Dominica"}, + 61: { + "de": "Dominikanische Republik", + "fr": "République Dominicaine", + "it": "Repubblica Dominicana", + }, + 62: {"de": "Ost Timor", "fr": "Timor Oriental", "it": "Timor Est"}, + 63: {"de": "Ecuador", "fr": "Équateur", "it": "Ecuador"}, + 64: {"de": "Ägypten", "fr": "Égyptien", "it": "Egitto"}, + 65: {"de": "El Salvador", "fr": "Salvador", "it": "El Salvador"}, + 66: { + "de": "Äquatorialguniea", + "fr": "Guinée équatoriale", + "it": "Guinea Equatoriale", + }, + 67: {"de": "Eritrea", "fr": "Érythrée", "it": "Eritrea"}, + 68: {"de": "Estland", "fr": "Estonia", "it": "Estonia"}, + 69: {"de": "Äthiopien", "fr": "Éthiopie", "it": "Etiopia"}, + 72: {"de": "Fidschi-Inseln", "fr": "Iles Fidji", "it": "Isole Figi"}, + 73: {"de": "Finnland", "fr": "Finlande", "it": "Finlandia"}, + 74: {"de": "Frankreich", "fr": "France", "it": "Francia"}, + 79: {"de": "Gabun", "fr": "Gabon", "it": "Gabon"}, + 80: {"de": "Gambia", "fr": "Gambie", "it": "Gambia"}, + 81: {"de": "Georgien", "fr": "Géorgie", "it": "Georgia"}, + 82: {"de": "Deutschland", "fr": "Allemagne", "it": "Germania"}, + 83: {"de": "Ghana", "fr": "Ghana", "it": "Ghana"}, + 85: {"de": "Griechenland", "fr": "Grèce", "it": "Grecia"}, + 90: {"de": "Guatemala", "fr": "Guatemala", "it": "Guatemala"}, + 92: {"de": "Guinea", "fr": "Guinée", "it": "Guinea"}, + 93: {"de": "Guinea-Bissau", "fr": "Guinée-Bissau", "it": "Guinea-Bissau"}, + 94: {"de": "Guyana", "fr": "Guyana", "it": "Guyana"}, + 95: {"de": "Haiti", "fr": "Haïti", "it": "Haiti"}, + 97: {"de": "Honduras", "fr": "Honduras", "it": "Honduras"}, + 99: {"de": "Ungarn", "fr": "Hongrie", "it": "Ungheria"}, + 100: {"de": "Island", "fr": "Icelande", "it": "Islanda"}, + 101: {"de": "Indien", "fr": "India", "it": "India"}, + 102: {"de": "Indonesien", "fr": "Indonésie", "it": "Indonesia"}, + 103: {"de": "Iran", "fr": "Iran", "it": "Iran"}, + 104: {"de": "Irak", "fr": "Irak", "it": "Iraq"}, + 105: {"de": "Irland", "fr": "Irlande", "it": "Irlanda"}, + 107: {"de": "Israel", "fr": "Israël", "it": "Israele"}, + 108: {"de": "Italien", "fr": "Italie", "it": "Italia"}, + 109: {"de": "Jamaika", "fr": "Jamaïque", "it": "Giamaica"}, + 110: {"de": "Japan", "fr": "Japon", "it": "Giappone"}, + 112: {"de": "Jordanien", "fr": "Jordanie", "it": "Giordania"}, + 113: {"de": "Kasachstan", "fr": "Kazakstan", "it": "Kazakistan"}, + 114: {"de": "Kenia", "fr": "Kénia", "it": "Kenia"}, + 115: {"de": "Kiribati", "fr": "Kiribati", "it": "Kiribati"}, + 116: { + "de": "Korea, Demokratische Volksrepublik", + "fr": "Corée du Nord", + "it": "Corea, Repubblica Popolare Democratica", + }, + 117: { + "de": "Korea, Republik (auch: Südkorea)", + "fr": "Corée du Sud", + "it": "Corea, Repubblica (anche: Corea del Sud)", + }, + 118: {"de": "Kuwait", "fr": "Koweït", "it": "Kuwait"}, + 119: {"de": "Kirgisistan", "fr": "Kirgistan", "it": "Kirghizistan"}, + 120: {"de": "Laos", "fr": "Laos", "it": "Laos"}, + 121: {"de": "Lettland", "fr": "Lettonie", "it": "Lettonia"}, + 122: {"de": "Libanon", "fr": "Lebanon", "it": "Libano"}, + 123: {"de": "Lesotho", "fr": "Lesotho", "it": "Lesotho"}, + 124: {"de": "Liberia", "fr": "Liberia", "it": "Liberia"}, + 125: {"de": "Libyen", "fr": "Libye", "it": "Libia"}, + 126: {"de": "Liechtenstein", "fr": "Liechtenstein", "it": "Liechtenstein"}, + 127: {"de": "Litauen", "fr": "Lituanie", "it": "Lituania"}, + 128: {"de": "Luxembourg", "fr": "Luxembourg", "it": "Lussemburgo"}, + 130: { + "de": "Nordmazedonien", + "fr": "Macédoine du Nord", + "it": "Macedonia del Nord", + }, + 131: {"de": "Madagaskar", "fr": "Madagascar", "it": "Madagascar"}, + 132: {"de": "Malawi", "fr": "Malawi", "it": "Malawi"}, + 133: {"de": "Malaysia", "fr": "Malaisie", "it": "Malesia"}, + 134: {"de": "Malediven", "fr": "Maldives", "it": "Maldive"}, + 135: {"de": "Mali", "fr": "Mali", "it": "Mali"}, + 136: {"de": "Malta", "fr": "Malte", "it": "Malta"}, + 137: {"de": "Marshall Inseln", "fr": "Iles Marshall", "it": "Isole Marshall"}, + 139: {"de": "Mauretanien", "fr": "Mauritanie", "it": "Mauritania"}, + 140: {"de": "Mauritius", "fr": "Ile Maurice", "it": "Mauritius"}, + 142: {"de": "Mexico", "fr": "Mexique", "it": "Messico"}, + 143: {"de": "Mikronesien", "fr": "Micronésie", "it": "Micronesia"}, + 144: {"de": "Moldavien", "fr": "Moldavie", "it": "Moldova"}, + 145: {"de": "Monaco", "fr": "Monaco", "it": "Monaco"}, + 146: {"de": "Mongolei", "fr": "Mongolie", "it": "Mongolia"}, + 148: {"de": "Marokko", "fr": "Morocco", "it": "Marocco"}, + 149: {"de": "Mosambik", "fr": "Mozambique", "it": "Mozambico"}, + 150: {"de": "Myanmar", "fr": "Myanmar", "it": "Myanmar"}, + 151: {"de": "Namibia", "fr": "Namibie", "it": "Namibia"}, + 152: {"de": "Nauru", "fr": "Nauru", "it": "Nauru"}, + 153: {"de": "Nepal", "fr": "Népal", "it": "Nepal"}, + 154: {"de": "Niederlande", "fr": "Pays-Bas", "it": "Paesi Bassi"}, + 157: {"de": "Neuseeland", "fr": "Nouvelle-Zélande", "it": "Nuova Zelanda"}, + 158: {"de": "Nicaragua", "fr": "Nicaragua", "it": "Nicaragua"}, + 159: {"de": "Niger", "fr": "Niger", "it": "Niger"}, + 160: {"de": "Nigeria", "fr": "Nigeria", "it": "Nigeria"}, + 164: {"de": "Norwegen", "fr": "Norvège", "it": "Norvegia"}, + 165: {"de": "Oman", "fr": "Oman", "it": "Oman"}, + 166: {"de": "Pakistan", "fr": "Pakistan", "it": "Pakistan"}, + 167: {"de": "Palau", "fr": "Palau", "it": "Palau"}, + 168: {"de": "Panama", "fr": "Panama", "it": "Panama"}, + 170: { + "de": "Papua-Neuguinea", + "fr": "Papouasie Nouvelle-Guinée", + "it": "Papua Nuova Guinea", + }, + 171: {"de": "Paraguay", "fr": "Paraguay", "it": "Paraguay"}, + 172: {"de": "Peru", "fr": "Pérou", "it": "Perù"}, + 173: {"de": "Philippinen", "fr": "Philippines", "it": "Filippine"}, + 175: {"de": "Polen", "fr": "Pologne", "it": "Polonia"}, + 176: {"de": "Portugal", "fr": "Portugal", "it": "Portogallo"}, + 178: {"de": "Katar", "fr": "Qatar", "it": "Qatar"}, + 180: {"de": "Rumänien", "fr": "Roumanie", "it": "Romania"}, + 181: {"de": "Russische Föderation", "fr": "Russie", "it": "Russia"}, + 182: {"de": "Ruanda", "fr": "Ruanda", "it": "Ruanda"}, + 183: { + "de": "Saint Kitts und Nevis", + "fr": "Saint-Kitts-et-Nevis", + "it": "Saint Kitts e Nevis", + }, + 184: {"de": "St. Lucia", "fr": "Sainte-Lucie", "it": "Santa Lucia"}, + 185: { + "de": "St. Vincent und die Grenadinen", + "fr": "Saint-Vincent-et-Les Grenadines", + "it": "Saint Vincent e Grenadine", + }, + 186: {"de": "Samoa", "fr": "Samoa", "it": "Samoa"}, + 187: {"de": "San Marino", "fr": "San Marino", "it": "San Marino"}, + 188: { + "de": "Sao Tome und Principe", + "fr": "Sao Tomé-et-Principe", + "it": "São Tomé e Principe", + }, + 189: {"de": "Saudi-Arabien", "fr": "Arabie Saoudite", "it": "Arabia Saudita"}, + 190: {"de": "Senegal", "fr": "Sénégal", "it": "Senegal"}, + 191: {"de": "Seychellen", "fr": "Seychelles", "it": "Seychelles"}, + 192: {"de": "Sierra Leone", "fr": "Sierra Leone", "it": "Sierra Leone"}, + 193: {"de": "Singapur", "fr": "Singapour", "it": "Singapore"}, + 194: {"de": "Slowakei", "fr": "Slovaquie", "it": "Slovacchia"}, + 195: {"de": "Slowenien", "fr": "Slovénie", "it": "Slovenia"}, + 196: {"de": "Salomonen", "fr": "Iles Salomon", "it": "Salomone"}, + 197: {"de": "Somalia", "fr": "Somalie", "it": "Somalia"}, + 198: {"de": "Südafrika", "fr": "Afrique du Sud", "it": "Africa del Sud"}, + 200: {"de": "Spanien", "fr": "Espagne", "it": "Spagna"}, + 201: {"de": "Sri Lanka", "fr": "Sri Lanka", "it": "Sri Lanka"}, + 204: {"de": "Sudan", "fr": "Soudan", "it": "Sudan"}, + 205: {"de": "Suriname", "fr": "Suriname", "it": "Suriname"}, + 207: {"de": "Swasiland", "fr": "Swaziland", "it": "Swaziland"}, + 208: {"de": "Schweden", "fr": "Suède", "it": "Svezia"}, + 209: {"de": "Schweiz", "fr": "Suisse", "it": "Svizzera"}, + 210: {"de": "Syrien", "fr": "Syrie", "it": "Siria"}, + 211: {"de": "Taiwan", "fr": "Taïwan", "it": "Taiwan"}, + 212: {"de": "Tadschikistan", "fr": "Tadjikistan", "it": "Tagikistan"}, + 213: {"de": "Tansania", "fr": "Tanzanie", "it": "Tanzania"}, + 214: {"de": "Thailand", "fr": "Thaïlande", "it": "Tailandia"}, + 215: {"de": "Togo", "fr": "Togo", "it": "Togo"}, + 217: {"de": "Tonga", "fr": "Tonga", "it": "Tonga"}, + 218: { + "de": "Trinidad und Tobago", + "fr": "Trinité-et-Tobago", + "it": "Trinidad e Tobago", + }, + 219: {"de": "Tunesien", "fr": "Tunisie", "it": "Tunisia"}, + 220: {"de": "Türkei", "fr": "Turchia", "it": "Turchia"}, + 221: {"de": "Turkmenistan", "fr": "Turkménistan", "it": "Turkmenistan"}, + 223: {"de": "Tuvalu", "fr": "Tuvalu", "it": "Tuvalu"}, + 224: {"de": "Uganda", "fr": "Ouganda", "it": "Uganda"}, + 225: {"de": "Ukraine", "fr": "Ukraine", "it": "Ucraina"}, + 226: { + "de": "Vereinigte Arabische Emirate", + "fr": "Émirats Arabes Unis", + "it": "Emirati Arabi Uniti", + }, + 227: {"de": "Großbritannien", "fr": "Royaume-Uni", "it": "Regno Unito"}, + 228: {"de": "USA", "fr": "États-Unis", "it": "Stati Uniti d´ America"}, + 230: {"de": "Uruguay", "fr": "Uruguay", "it": "Uruguay"}, + 231: {"de": "Usbekistan", "fr": "Ouzbékistan", "it": "Uzbekistan"}, + 232: {"de": "Vanuatu", "fr": "Vanuatu", "it": "Vanuatu"}, + 233: {"de": "Vatikanstadt", "fr": "Vatican", "it": "Città del Vaticano"}, + 234: {"de": "Venezuela", "fr": "Venezuela", "it": "Venezuela"}, + 235: {"de": "Vietnam", "fr": "Viêtnam", "it": "Vietnam"}, + 239: {"de": "Sahara", "fr": "Sahara", "it": "Sahara"}, + 240: {"de": "Jemen", "fr": "Yémen", "it": "Yemen"}, + 241: {"de": "Serbien", "fr": "Serbie", "it": "Serbia"}, + 242: {"de": "Montenegro", "fr": "Monténégro", "it": "Montenegro"}, + 243: {"de": "Sambia", "fr": "Zambie", "it": "Zambia"}, + 244: {"de": "Simbabwe", "fr": "Zimbabwe", "it": "Zimbabwe"}, + 245: {"de": "Hong Kong", "fr": "Hong Kong", "it": "Hong Kong"}, + 246: {"de": "Falkland Inseln", "fr": "Îles Malouines", "it": "Isole Falkland"}, + 247: {"de": "Aruba", "fr": "Aruba", "it": "Aruba"}, + 248: {"de": "Bermuda", "fr": "Bermudes", "it": "Bermuda"}, + 249: { + "de": "Britische Jungferninseln", + "fr": "Îles Vierges britanniques", + "it": "Isole Vergini britanniche", + }, + 250: {"de": "Curaçao", "fr": "Curaçao", "it": "Curaçao"}, + 251: {"de": "Anguilla", "fr": "Anguilla", "it": "Anguilla"}, + 252: {"de": "Montserrat", "fr": "Montserrat", "it": "Montserrat"}, + 253: { + "de": "Bonaire, Sint Eustatius und Saba", + "fr": "Bonaire, Saint-Eustache et Saba", + "it": "Bonaire, Sint Eustatius e Saba", + }, + 254: {"de": "Cayman Inseln", "fr": "Îles Caïmans", "it": "Isole Cayman"}, + 255: {"de": "Sint Maarten", "fr": "Saint-Martin", "it": "Sint Maarten"}, + 256: { + "de": "Turks- und Caicos-Inseln", + "fr": "Îles Turks et Caïques", + "it": "Turks e Caicos", + }, + 257: {"de": "Saint-Barth", "fr": "Saint-Barthélemy", "it": "Saint-Barth"}, + 258: { + "de": "Palästinensisches Gebiet", + "fr": "Territoires palestiniens occupés", + "it": "Territori palestinesi", + }, + 259: {"de": "Kosovo", "fr": "Kosovo", "it": "Kosovo"}, + 260: {"de": "Gibraltar", "fr": "Gibraltar", "it": "Gibilterra"}, + 261: {"de": "Neukaledonien", "fr": "Nouvelle-Calédonie", "it": "Nuova Caledonia"}, + 262: { + "de": "Französisch-Polynesien", + "fr": "Polynésie française", + "it": "Polinesia francese", + }, + 310: { + "de": "Niederländische Antillen", + "fr": "Antilles néerlandaises", + "it": "Antille olandesi", + }, + 311: {"de": "Antarktika", "fr": "Antarctique", "it": "Antartide"}, + 312: { + "de": "Amerikanisch-Samoa", + "fr": "Samoa américaines", + "it": "Samoa americane", + }, + 313: {"de": "Åland", "fr": "Åland", "it": "Åland"}, + 314: {"de": "Bouvetinsel", "fr": "Île Bouvet", "it": "Isola Bouvet"}, + 315: {"de": "Kokosinseln", "fr": "Îles Cocos", "it": "Isole Cocos (Keeling)"}, + 316: {"de": "Cookinseln", "fr": "Îles Cook", "it": "Isole Cook"}, + 317: { + "de": "Clipperton-Insel", + "fr": "Île de Clipperton", + "it": "Isola di Clipperton", + }, + 318: {"de": "Weihnachtsinsel", "fr": "Île Christmas", "it": "Isola di Natale"}, + 319: {"de": "Färöer-Inseln", "fr": "Îles Féroé", "it": "Isole Färöer"}, + 320: { + "de": "Französisch-Guayana", + "fr": "Guyane française", + "it": "Guyana francese", + }, + 321: {"de": "Guernsey", "fr": "Guernsey", "it": "Guernsey"}, + 322: {"de": "Grönland", "fr": "Groenland", "it": "Groenlandia"}, + 323: {"de": "Guadeloupe", "fr": "Guadeloupe", "it": "Guadalupa"}, + 324: { + "de": "Südgeorgien und die Südlichen Sandwichinseln", + "fr": "Géorgie du Sud et Îles Sandwich du Sud", + "it": "Georgia del Sud e Sandwich Australi", + }, + 325: {"de": "Guam", "fr": "Guam", "it": "Guam"}, + 326: { + "de": "Heard und McDonaldinseln", + "fr": "Îles Heard et McDonald", + "it": "Isola Heard e Isole McDonald", + }, + 327: {"de": "Insel Man", "fr": "Île de Man", "it": "Isola di Man"}, + 328: { + "de": "Britisches Territorium im Indischen Ozean", + "fr": "Territoire britannique de l´océan Indien", + "it": "Territori Britannici dell´Oceano Indiano", + }, + 329: {"de": "Jersey", "fr": "Jersey", "it": "Jersey"}, + 330: {"de": "Saint-Martin", "fr": "Saint-Martin", "it": "Saint Martin"}, + 331: {"de": "Macau", "fr": "Macao", "it": "Macao"}, + 332: { + "de": "Nördliche Marianen", + "fr": "Îles Mariannes du Nord", + "it": "Isole Marianne Settentrionali", + }, + 333: {"de": "Martinique", "fr": "Martinique", "it": "Martinica"}, + 334: {"de": "Norfolkinsel", "fr": "Île Norfolk", "it": "Isola Norfolk"}, + 335: {"de": "Niue", "fr": "Niue", "it": "Niue"}, + 336: { + "de": "Saint-Pierre und Miquelon", + "fr": "Saint-Pierre-et-Miquelon", + "it": "Saint-Pierre e Miquelon", + }, + 337: {"de": "Pitcairninseln", "fr": "Îles Pitcairn", "it": "Isole Pitcairn"}, + 338: {"de": "Puerto Rico", "fr": "Porto Rico", "it": "Porto Rico"}, + 339: {"de": "La Réunion", "fr": "La Réunion", "it": "Isola della Riunione"}, + 340: { + "de": "St. Helena, Ascension und Tristan da Cunha", + "fr": "Sainte-Hélène, Ascension et Tristan da Cunha", + "it": "Sant´Elena, Ascensione e Tristan da Cunha", + }, + 341: { + "de": "Spitzbergen, Jan Mayen", + "fr": "Spitzberg, Jan Mayen", + "it": "Svalbard e Jan Mayen", + }, + 342: {"de": "Südsudan", "fr": "Sud-Soudan", "it": "Sudan del Sud"}, + 343: { + "de": "Französische Süd- und Antarktisgebiete", + "fr": "Terres australes et antarctiques françaises", + "it": "Territori australi e antartico francese", + }, + 344: {"de": "Tokelau", "fr": "Tokelau", "it": "Tokelau"}, + 345: { + "de": "United States Minor Outlying Islands", + "fr": "Îles mineures éloignées des États-Unis", + "it": "Isole Minori Esterne degli Stati Uniti", + }, + 346: { + "de": "Amerikanische Jungferninseln", + "fr": "Îles Vierges américaines", + "it": "Isole Vergini Americane", + }, + 347: {"de": "Wallis und Futuna", "fr": "Wallis et Futuna", "it": "Wallis e Futuna"}, + 348: {"de": "Mayotte", "fr": "Mayotte", "it": "Mayotte"}, +} + + +def add_countries(apps=None, schema_editor=None): + if apps is None: + # pylint: disable=import-outside-toplevel + from vbv_lernwelt.shop.models import Country + else: + Country = apps.get_model("shop", "Country") + + for country_id, country_name in countries.items(): + Country.objects.get_or_create( + country_id=country_id, + name_de=country_name["de"], + name_fr=country_name["fr"], + name_it=country_name["it"], + ) + + +def remove_countries(apps=None, schema_editor=None): + if apps is None: + # pylint: disable=import-outside-toplevel + from vbv_lernwelt.shop.models import Country + else: + Country = apps.get_model("shop", "Country") + + for country_id in countries.keys(): + Country.objects.filter( + country_id=country_id, + ).delete() diff --git a/server/vbv_lernwelt/shop/models.py b/server/vbv_lernwelt/shop/models.py new file mode 100644 index 00000000..e2f728b0 --- /dev/null +++ b/server/vbv_lernwelt/shop/models.py @@ -0,0 +1,117 @@ +from django.db import models + + +class Country(models.Model): + country_id = models.IntegerField(primary_key=True) + name_de = models.CharField(max_length=255) + name_fr = models.CharField(max_length=255) + name_it = models.CharField(max_length=255) + + def __str__(self): + return f"{self.name_de} ({self.country_id})" + + class Meta: + verbose_name = "Country" + verbose_name_plural = "Countries" + ordering = ["country_id"] + + +class BillingAddress(models.Model): + """ + Draft of a billing address for a purchase from the shop. + """ + + user = models.OneToOneField( + "core.User", + on_delete=models.CASCADE, + primary_key=True, + ) + + # user + first_name = models.CharField(max_length=255, blank=True) + last_name = models.CharField(max_length=255, blank=True) + street = models.CharField(max_length=255, blank=True) + street_number = models.CharField(max_length=255, blank=True) + postal_code = models.CharField(max_length=255, blank=True) + city = models.CharField(max_length=255, blank=True) + country = models.CharField(max_length=255, blank=True) + + # company (optional) + company_name = models.CharField(max_length=255, blank=True) + company_street = models.CharField(max_length=255, blank=True) + company_street_number = models.CharField(max_length=255, blank=True) + company_postal_code = models.CharField(max_length=255, blank=True) + company_city = models.CharField(max_length=255, blank=True) + company_country = models.CharField(max_length=255, blank=True) + + +class Product(models.Model): + sku = models.CharField(max_length=255, primary_key=True) + price = models.IntegerField() # 10_00 = 10.00 CHF + name = models.CharField(max_length=255) + description = models.CharField(max_length=255) + + +class CheckoutState(models.TextChoices): + """ + The state of a checkout process transaction. + + PAID: Datatrans transaction settled/transmitted. + ONGOING: Any state that is not final (e.g. initialized, challenge_ongoing, etc.) + + 1) We use the `autoSettle` feature of DataTrans! + -> https://docs.datatrans.ch/docs/after-the-payment + -> https://api-reference.datatrans.ch/#tag/v1transactions/operation/status + + 2) Difference between `settled` and `transmitted`: + - https://www.datatrans.ch/en/know-how/faq/#what-does-the-status-transaction-settled-or-settledtransmitted-mean + + 3) Related code: init_transaction and get_transaction_state in shop/services.py + """ + + ONGOING = "ongoing" + PAID = "paid" + CANCELED = "canceled" + FAILED = "failed" + + +class CheckoutInformation(models.Model): + user = models.ForeignKey("core.User", on_delete=models.PROTECT) + + product_sku = models.CharField(max_length=255) + product_name = models.CharField(max_length=255) + product_description = models.CharField(max_length=255) + product_price = models.IntegerField( + help_text="The total price of the product in centimes -> 1000 = 10.00 CHF" + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + state = models.CharField( + max_length=50, + choices=CheckoutState.choices, + ) + + invoice_transmitted_at = models.DateTimeField(blank=True, null=True) + transaction_id = models.CharField(max_length=255) + + # end user (required) + first_name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255) + street = models.CharField(max_length=255) + street_number = models.CharField(max_length=255) + postal_code = models.CharField(max_length=255) + city = models.CharField(max_length=255) + country = models.CharField(max_length=255) + + # company (optional) + company_name = models.CharField(max_length=255, blank=True) + company_street = models.CharField(max_length=255, blank=True) + company_street_number = models.CharField(max_length=255, blank=True) + company_postal_code = models.CharField(max_length=255, blank=True) + company_city = models.CharField(max_length=255, blank=True) + company_country = models.CharField(max_length=255, blank=True) + + # webhook metadata + webhook_history = models.JSONField(default=list) diff --git a/server/vbv_lernwelt/shop/serializers.py b/server/vbv_lernwelt/shop/serializers.py new file mode 100644 index 00000000..89a1787c --- /dev/null +++ b/server/vbv_lernwelt/shop/serializers.py @@ -0,0 +1,29 @@ +from rest_framework import serializers + +from .models import BillingAddress, Country + + +class BillingAddressSerializer(serializers.ModelSerializer): + class Meta: + model = BillingAddress + fields = [ + "first_name", + "last_name", + "street", + "street_number", + "postal_code", + "city", + "country", + "company_name", + "company_street", + "company_street_number", + "company_postal_code", + "company_city", + "company_country", + ] + + +class CountrySerializer(serializers.ModelSerializer): + class Meta: + model = Country + fields = "__all__" diff --git a/server/vbv_lernwelt/shop/services.py b/server/vbv_lernwelt/shop/services.py new file mode 100644 index 00000000..9a2d1faa --- /dev/null +++ b/server/vbv_lernwelt/shop/services.py @@ -0,0 +1,140 @@ +import hashlib +import hmac +import uuid + +import requests +import structlog +from django.conf import settings + +from vbv_lernwelt.core.admin import User +from vbv_lernwelt.shop.models import CheckoutState + +logger = structlog.get_logger(__name__) + + +class InitTransactionException(Exception): + pass + + +def is_signature_valid( + signature: str, + payload: bytes, + hmac_key: str = settings.DATATRANS_HMAC_KEY, +): + """ + See the docs: https://docs.datatrans.ch/docs/additional-security + """ + + try: + timestamp = signature.split(",")[0].split("=")[1] + s0_expected = signature.split(",")[1].split("=")[1] + + key_hex_bytes = bytes.fromhex(hmac_key) + timestamp_bytes = bytes(timestamp, "utf-8") + + s0_actual = hmac.new( + key_hex_bytes, timestamp_bytes + payload, hashlib.sha256 + ).hexdigest() + except (IndexError, ValueError): + logger.warning( + "Invalid signature format Expected format: t=TIMESTAMP,s0=XXXX", + signature=signature, + ) + return False + + return s0_actual == s0_expected + + +def init_transaction( + user: User, + amount_chf_centimes: int, + redirect_url_success: str, + redirect_url_error: str, + redirect_url_cancel: str, + webhook_url: str, +): + if overwrite := settings.DATATRANS_DEBUG_WEBHOOK_OVERWRITE: + logger.warning( + "APPLYING DEBUG DATATRANS WEBHOOK OVERWRITE!", + webhook_url=overwrite, + ) + webhook_url = overwrite + + payload = { + # We use autoSettle=True, so that we don't have to settle the transaction: + # -> Be aware that autoSettle has implications of the possible transaction states + "autoSettle": True, + "amount": amount_chf_centimes, + "currency": "CHF", + "language": user.language, + "refno": str(uuid.uuid4()), + "webhook": {"url": webhook_url}, + "redirect": { + "successUrl": redirect_url_success, + "errorUrl": redirect_url_error, + "cancelUrl": redirect_url_cancel, + }, + } + + logger.info("Initiating transaction", payload=payload) + + response = requests.post( + url=f"{settings.DATATRANS_API_ENDPOINT}/v1/transactions", + json=payload, + headers={ + "Authorization": f"Basic {settings.DATATRANS_BASIC_AUTH_KEY}", + "Content-Type": "application/json", + }, + ) + + if response.status_code == 201: + transaction_id = response.json()["transactionId"] + logger.info("Transaction initiated", transaction_id=transaction_id) + return transaction_id + else: + raise InitTransactionException( + "Transaction initiation failed:", + response.json().get("error"), + ) + + +def get_transaction_state( + transaction_id: str, +) -> CheckoutState: + response = requests.get( + f"{settings.DATATRANS_API_ENDPOINT}/v1/transactions/{transaction_id}", + headers={ + "Authorization": f"Basic {settings.DATATRANS_BASIC_AUTH_KEY}", + "Content-Type": "application/json", + }, + ) + + transaction_state = response.json()["status"] + + logger.info( + "Transaction status retrieved", + status_code=response.status_code, + response=transaction_state, + ) + + return datatrans_state_to_checkout_state(transaction_state) + + +def get_payment_url(transaction_id: str): + return f"{settings.DATATRANS_PAY_URL}/v1/start/{transaction_id}" + + +def datatrans_state_to_checkout_state(datatrans_transaction_state) -> CheckoutState: + """ + https://api-reference.datatrans.ch/#tag/v1transactions/operation/status + """ + if datatrans_transaction_state in ["settled", "transmitted"]: + return CheckoutState.PAID + elif datatrans_transaction_state == "failed": + return CheckoutState.FAILED + elif datatrans_transaction_state == "canceled": + return CheckoutState.CANCELED + else: + # An intermediate state such as "initialized", "challenge_ongoing", etc. + # -> we don't care about those states, we only care about final states here. + return CheckoutState.ONGOING diff --git a/server/vbv_lernwelt/shop/tests/__init__.py b/server/vbv_lernwelt/shop/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/shop/tests/test_billing_address_api.py b/server/vbv_lernwelt/shop/tests/test_billing_address_api.py new file mode 100644 index 00000000..c9e2f0cb --- /dev/null +++ b/server/vbv_lernwelt/shop/tests/test_billing_address_api.py @@ -0,0 +1,106 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from vbv_lernwelt.core.admin import User +from vbv_lernwelt.shop.models import BillingAddress + + +class BillingAddressViewTest(APITestCase): + def setUp(self) -> None: + self.user = User.objects.create_user( + "testuser", "test@example.com", "testpassword" + ) + self.client.login(username="testuser", password="testpassword") + + self.billing_address = BillingAddress.objects.create( + user=self.user, + first_name="John", + last_name="Doe", + street="123 Main St", + street_number="45A", + postal_code="12345", + city="Test City", + country="Test Country", + company_name="Test Company", + company_street="456 Company St", + company_street_number="67B", + company_postal_code="67890", + company_city="Company City", + company_country="Company Country", + ) + + def test_get_billing_address(self) -> None: + # GIVEN + # user is logged in and has a billing address + + # WHEN + url = reverse("get-billing-address") + response = self.client.get(url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["first_name"], "John") + self.assertEqual(response.data["last_name"], "Doe") + self.assertEqual(response.data["street"], "123 Main St") + self.assertEqual(response.data["street_number"], "45A") + self.assertEqual(response.data["postal_code"], "12345") + self.assertEqual(response.data["city"], "Test City") + self.assertEqual(response.data["country"], "Test Country") + self.assertEqual(response.data["company_name"], "Test Company") + self.assertEqual(response.data["company_street"], "456 Company St") + self.assertEqual(response.data["company_street_number"], "67B") + self.assertEqual(response.data["company_postal_code"], "67890") + self.assertEqual(response.data["company_city"], "Company City") + self.assertEqual(response.data["company_country"], "Company Country") + + def test_update_billing_address(self) -> None: + # GIVEN + new_data = { + "first_name": "Jane", + "last_name": "Smith", + "street": "789 New St", + "street_number": "101C", + "postal_code": "54321", + "city": "New City", + "country": "New Country", + "company_name": "New Company", + "company_street": "789 Company St", + "company_street_number": "102D", + "company_postal_code": "98765", + "company_city": "New Company City", + "company_country": "New Company Country", + } + + # WHEN + url = reverse("update-billing-address") + response = self.client.put(url, new_data) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + updated_address = BillingAddress.objects.get(user=self.user) + self.assertEqual(updated_address.first_name, "Jane") + self.assertEqual(updated_address.last_name, "Smith") + self.assertEqual(updated_address.street, "789 New St") + self.assertEqual(updated_address.street_number, "101C") + self.assertEqual(updated_address.postal_code, "54321") + self.assertEqual(updated_address.city, "New City") + self.assertEqual(updated_address.country, "New Country") + self.assertEqual(updated_address.company_name, "New Company") + self.assertEqual(updated_address.company_street, "789 Company St") + self.assertEqual(updated_address.company_street_number, "102D") + self.assertEqual(updated_address.company_postal_code, "98765") + self.assertEqual(updated_address.company_city, "New Company City") + self.assertEqual(updated_address.company_country, "New Company Country") + + def test_unauthenticated_access(self) -> None: + # GIVEN + self.client.logout() + + # WHEN + get_response = self.client.get(reverse("get-billing-address")) + put_response = self.client.put(reverse("update-billing-address"), {}) + + # THEN + self.assertTrue(get_response["Location"], "/login/") + self.assertTrue(put_response["Location"], "/login/") diff --git a/server/vbv_lernwelt/shop/tests/test_checkout_api.py b/server/vbv_lernwelt/shop/tests/test_checkout_api.py new file mode 100644 index 00000000..bd7e942b --- /dev/null +++ b/server/vbv_lernwelt/shop/tests/test_checkout_api.py @@ -0,0 +1,305 @@ +from unittest.mock import patch + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from vbv_lernwelt.core.admin import User +from vbv_lernwelt.shop.const import VV_DE_PRODUCT_SKU +from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState, Product +from vbv_lernwelt.shop.services import InitTransactionException + +USER_USERNAME = "testuser" +USER_EMAIL = "test@example.com" +USER_PASSWORD = "testpassword" + +TEST_ADDRESS_DATA = { + "first_name": "Test", + "last_name": "User", + "street": "Test Street", + "street_number": "1", + "postal_code": "1234", + "city": "Test City", + "country": "Test Country", + "company_name": "Test Company", + "company_street": "Test Company Street", + "company_street_number": "1", + "company_postal_code": "1234", + "company_city": "Test Company City", + "company_country": "Test Company Country", +} + +REDIRECT_URL = "http://testserver/redirect-url" + + +class CheckoutAPITestCase(APITestCase): + def setUp(self) -> None: + Product.objects.create( + sku=VV_DE_PRODUCT_SKU, + price=324_30, + description="VV", + name="VV", + ) + + self.user = User.objects.create_user( + username=USER_USERNAME, + email=USER_EMAIL, + password=USER_PASSWORD, + is_active=True, + ) + + self.client.login(username=USER_USERNAME, password=USER_PASSWORD) + + @patch("vbv_lernwelt.shop.views.init_transaction") + def test_checkout_happy_case(self, mock_init_transaction): + # GIVEN + mock_init_transaction.return_value = "1234567890" + + # WHEN + response = self.client.post( + path=reverse("checkout-vv"), + format="json", + data={ + "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, + "address": TEST_ADDRESS_DATA, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + f"https://pay.sandbox.datatrans.com/v1/start/1234567890", + response.json()["next_step_url"], + ) + + self.assertTrue( + CheckoutInformation.objects.filter( + user=self.user, + product_sku=VV_DE_PRODUCT_SKU, + state=CheckoutState.ONGOING, + ).exists() + ) + + mock_init_transaction.assert_called_once_with( + user=self.user, + amount_chf_centimes=324_30, + redirect_url_success=f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/complete", + redirect_url_error=f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/address?error", + redirect_url_cancel=f"{REDIRECT_URL}/", + webhook_url=f"{REDIRECT_URL}/api/shop/transaction/webhook/", + ) + + @patch("vbv_lernwelt.shop.views.init_transaction") + def test_incomplete_setup(self, mock_init_transaction): + # GIVEN + Product.objects.all().delete() + mock_init_transaction.return_value = "1234567890" + + # WHEN + response = self.client.post( + path=reverse("checkout-vv"), + format="json", + data={ + "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, + "address": TEST_ADDRESS_DATA, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + + expected = ( + f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/address?error&" + f"message=vv-de_product_sku_does_not_exist_needs_to_be_created_first" + ) + + self.assertEqual(expected, response.json()["next_step_url"]) + + @patch("vbv_lernwelt.shop.views.init_transaction") + def test_checkout_init_transaction_exception(self, mock_init_transaction): + # GIVEN + mock_init_transaction.side_effect = InitTransactionException( + "Something went wrong" + ) + + # WHEN + response = self.client.post( + path=reverse("checkout-vv"), + format="json", + data={ + "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, + "address": TEST_ADDRESS_DATA, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/address?error", + response.json()["next_step_url"], + ) + + self.assertEqual( + 0, + CheckoutInformation.objects.count(), + ) + + def test_checkout_already_paid(self): + # GIVEN + CheckoutInformation.objects.create( + user=self.user, + product_sku=VV_DE_PRODUCT_SKU, + product_price=0, + state=CheckoutState.PAID, + ) + + # WHEN + response = self.client.post( + path=reverse("checkout-vv"), + format="json", + data={ + "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, + "address": TEST_ADDRESS_DATA, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + "/", + response.json()["next_step_url"], + ) + + @patch("vbv_lernwelt.shop.views.init_transaction") + def test_checkout_double_checkout(self, mock_init_transaction): + """Advise by Datatrans: Just create a new transaction.""" + # GIVEN + + # existing checkout + transaction_id_previous = "1234567890" + CheckoutInformation.objects.create( + user=self.user, + product_sku=VV_DE_PRODUCT_SKU, + product_price=0, + state=CheckoutState.ONGOING, + transaction_id=transaction_id_previous, + ) + + # new checkout / transaction + transaction_id_next = "9999999999" + mock_init_transaction.return_value = transaction_id_next + + # WHEN + response = self.client.post( + path=reverse("checkout-vv"), + format="json", + data={ + "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, + "address": TEST_ADDRESS_DATA, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id_next}", + response.json()["next_step_url"], + ) + + # check that we have two checkouts + # (one previous and one new) + self.assertEqual( + 2, + CheckoutInformation.objects.count(), + ) + + # previous checkout + self.assertTrue( + CheckoutInformation.objects.filter( + user=self.user, + product_sku=VV_DE_PRODUCT_SKU, + state=CheckoutState.ONGOING, + transaction_id=transaction_id_previous, + ).exists() + ) + + # new checkout + self.assertTrue( + CheckoutInformation.objects.filter( + user=self.user, + product_sku=VV_DE_PRODUCT_SKU, + state=CheckoutState.ONGOING, + transaction_id=transaction_id_next, + ).exists() + ) + + @patch("vbv_lernwelt.shop.views.init_transaction") + def test_checkout_failed_creates_new(self, mock_init_transaction): + # GIVEN + state = CheckoutState.FAILED + transaction_id = "1234567890" + mock_init_transaction.return_value = transaction_id + + CheckoutInformation.objects.create( + user=self.user, + product_sku=VV_DE_PRODUCT_SKU, + product_price=0, + state=state, + transaction_id="0000000000", + ) + + # WHEN + response = self.client.post( + path=reverse("checkout-vv"), + format="json", + data={ + "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, + "address": TEST_ADDRESS_DATA, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id}", + response.json()["next_step_url"], + ) + + @patch("vbv_lernwelt.shop.views.init_transaction") + def test_checkout_cancelled_creates_new(self, mock_init_transaction): + # GIVEN + state = CheckoutState.CANCELED + transaction_id = "1234567899" + mock_init_transaction.return_value = transaction_id + + CheckoutInformation.objects.create( + user=self.user, + product_sku=VV_DE_PRODUCT_SKU, + product_price=0, + state=state, + transaction_id="1111111111", + ) + + # WHEN + response = self.client.post( + path=reverse("checkout-vv"), + format="json", + data={ + "redirect_url": REDIRECT_URL, + "product": VV_DE_PRODUCT_SKU, + "address": TEST_ADDRESS_DATA, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id}", + response.json()["next_step_url"], + ) diff --git a/server/vbv_lernwelt/shop/tests/test_datatrans_service.py b/server/vbv_lernwelt/shop/tests/test_datatrans_service.py new file mode 100644 index 00000000..f4a54039 --- /dev/null +++ b/server/vbv_lernwelt/shop/tests/test_datatrans_service.py @@ -0,0 +1,99 @@ +import uuid +from unittest.mock import patch + +from django.test import override_settings, TestCase + +from vbv_lernwelt.core.models import User +from vbv_lernwelt.shop.services import ( + get_payment_url, + init_transaction, + InitTransactionException, +) + +REDIRECT_URL = "http://testserver/redirect-url" + + +class DatatransServiceTest(TestCase): + def setUp(self): + self.user = User.objects.create_user( + username=uuid.uuid4().hex, + email=uuid.uuid4().hex, + password="password", + is_active=True, + ) + + @override_settings(DATATRANS_BASIC_AUTH_KEY="BASIC_AUTH_KEY") + @patch("vbv_lernwelt.shop.services.requests.post") + @patch("vbv_lernwelt.shop.services.uuid.uuid4") + def test_init_transaction_201(self, mock_uuid, mock_post): + # GIVEN + mock_uuid.return_value = uuid.uuid4() + mock_post.return_value.status_code = 201 + mock_post.return_value.json.return_value = { + "transactionId": 1234567890, + } + + self.user.language = "it" + + # WHEN + transaction_id = init_transaction( + user=self.user, + amount_chf_centimes=324_30, + redirect_url_success=f"{REDIRECT_URL}/success", + redirect_url_error=f"{REDIRECT_URL}/error", + redirect_url_cancel=f"{REDIRECT_URL}/cancel", + webhook_url=f"{REDIRECT_URL}/webhook", + ) + + self.assertEqual(1234567890, transaction_id) + + # THEN + mock_post.assert_called_once_with( + url="https://api.sandbox.datatrans.com/v1/transactions", + json={ + "autoSettle": True, + "amount": 324_30, + "currency": "CHF", + "language": self.user.language, + "refno": str(mock_uuid()), + "webhook": {"url": f"{REDIRECT_URL}/webhook"}, + "redirect": { + "successUrl": f"{REDIRECT_URL}/success", + "errorUrl": f"{REDIRECT_URL}/error", + "cancelUrl": f"{REDIRECT_URL}/cancel", + }, + }, + headers={ + "Authorization": "Basic BASIC_AUTH_KEY", + "Content-Type": "application/json", + }, + ) + + @patch("vbv_lernwelt.shop.services.requests.post") + def test_init_transaction_500(self, mock_post): + # GIVEN + mock_post.return_value.status_code = 500 + + # WHEN / THEN + with self.assertRaises(InitTransactionException): + init_transaction( + user=self.user, + amount_chf_centimes=324_30, + redirect_url_success=f"/success", + redirect_url_error=f"/error", + redirect_url_cancel=f"/cancel", + webhook_url=f"/webhook", + ) + + def test_get_payment_url(self): + # GIVEN + transaction_id = "1234567890" + + # WHEN + url = get_payment_url(transaction_id) + + # THEN + self.assertEqual( + url, + f"https://pay.sandbox.datatrans.com/v1/start/{transaction_id}", + ) diff --git a/server/vbv_lernwelt/shop/tests/test_datatrans_signature.py b/server/vbv_lernwelt/shop/tests/test_datatrans_signature.py new file mode 100644 index 00000000..45f89ddc --- /dev/null +++ b/server/vbv_lernwelt/shop/tests/test_datatrans_signature.py @@ -0,0 +1,45 @@ +from unittest import TestCase + +from vbv_lernwelt.shop.services import is_signature_valid + + +class DatatransSigningTestCase(TestCase): + """ + Test based on the example from the docs. + Key is from their example, not ours! + """ + + HMAC_KEY_FROM_THE_DOCS_NOT_HAZARDOUS = ( + "861bbfc01e089259091927d6ad7f71c8" + "b46b7ee13499574e83c633b74cdc29e3" + "b7e262e41318c8425c520f146986675f" + "dd58a4531a01c99f06da378fdab0414a" + ) + + def test_signature_happy_ala_docs(self): + # GIVEN + payload = b"HELLO" + signature = "t=1605697463367,s0=82ef9a8178dcb4df0b71540fa06d7da826ecb26e1977e230bdc8c9d6f9f1af84" + + # WHEN / THEN + self.assertTrue( + is_signature_valid( + hmac_key=self.HMAC_KEY_FROM_THE_DOCS_NOT_HAZARDOUS, + payload=payload, + signature=signature, + ) + ) + + def test_signature_not_happy(self): + # GIVEN + tampered_payload = b"HELLO=I=TAMPERED=WITH=PAYLOAD=HIHI=I=AM=EVIL" + signature = "t=1605697463367,s0=82ef9a8178dcb4df0b71540fa06d7da826ecb26e1977e230bdc8c9d6f9f1af84" + + # THEN + self.assertFalse( + is_signature_valid( + hmac_key=self.HMAC_KEY_FROM_THE_DOCS_NOT_HAZARDOUS, + payload=tampered_payload, + signature=signature, + ) + ) diff --git a/server/vbv_lernwelt/shop/tests/test_datatrans_webhook.py b/server/vbv_lernwelt/shop/tests/test_datatrans_webhook.py new file mode 100644 index 00000000..b594d95e --- /dev/null +++ b/server/vbv_lernwelt/shop/tests/test_datatrans_webhook.py @@ -0,0 +1,279 @@ +from unittest.mock import ANY, patch + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from vbv_lernwelt.core.admin import User +from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN_ID +from vbv_lernwelt.course.creators.test_utils import create_course, create_course_session +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.notify.email.email_services import EmailTemplate +from vbv_lernwelt.shop.const import VV_DE_PRODUCT_SKU +from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState, Product + + +def create_checkout_information( + user: User, + transaction_id: str, + state: CheckoutState, +) -> CheckoutInformation: + return CheckoutInformation.objects.create( + user=user, + transaction_id=transaction_id, + product_sku=VV_DE_PRODUCT_SKU, + product_price=324_30, + state=state.value, + ) + + +class DatatransWebhookTestCase(APITestCase): + def setUp(self) -> None: + course, _ = create_course( + title="VV_in_DE", + # needed for VV_DE_PRODUCT_SKU + _id=COURSE_VERSICHERUNGSVERMITTLERIN_ID, + ) + + create_course_session(course=course, title="Versicherungsvermittler/-in DE") + + self.user = User.objects.create_user( + username="testuser", + email="test@user.com", + password="testpassword", + is_active=True, + ) + + self.product = Product.objects.create( + sku=VV_DE_PRODUCT_SKU, + price=324_30, + description="VV", + name="VV", + ) + + def test_webhook_unsigned_payload(self): + # GIVEN + payload = { + "transactionId": "1234567890", + "status": "settled", + } + + headers = {"Datatrans-Signature": "t=1605697463367,s0=guessed"} + + # WHEN + response = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + data=payload, + headers=headers, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.json(), + {"status": "invalid signature"}, + ) + + @patch("vbv_lernwelt.shop.views.is_signature_valid") + @patch("vbv_lernwelt.shop.views.send_email") + def test_webhook_settled_transmitted_paid( + self, mock_send_mail, mock_is_signature_valid + ): + # GIVEN + transaction_id = "1234567890" + + create_checkout_information( + user=self.user, + transaction_id=transaction_id, + state=CheckoutState.ONGOING, + ) + + mock_is_signature_valid.return_value = True + + # WHEN + + # ~immediately after successful payment + response_settled = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + headers={"Datatrans-Signature": ""}, + data={ + "status": "settled", + "transactionId": transaction_id, + }, + ) + + # ~24h later + response_transmitted = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + headers={"Datatrans-Signature": ""}, + data={ + "status": "transmitted", + "transactionId": transaction_id, + }, + ) + + # THEN + self.assertEqual(status.HTTP_200_OK, response_settled.status_code) + self.assertEqual(status.HTTP_200_OK, response_transmitted.status_code) + + self.assertEqual( + CheckoutState.PAID.value, + CheckoutInformation.objects.get(transaction_id=transaction_id).state, + ) + + self.assertEqual( + 1, + CourseSessionUser.objects.count(), + ) + + self.assertEqual( + self.user, + CourseSessionUser.objects.first().user, + ) + + self.assertEqual( + COURSE_VERSICHERUNGSVERMITTLERIN_ID, + CourseSessionUser.objects.first().course_session.course.id, + ) + + self.assertEqual( + 1, + mock_send_mail.call_count, + ) + + mock_send_mail.assert_called_once_with( + template=EmailTemplate.WELCOME_MAIL_VV, + recipient_email=self.user.email, + template_data={ + "course": "Versicherungsvermittler/-in (Deutsch)", + "target_url": "https://my.vbv-afa.ch/", + }, + template_language=self.user.language, + fail_silently=ANY, + ) + + @patch("vbv_lernwelt.shop.views.is_signature_valid") + def test_webhook_updates_webhook_history(self, mock_is_signature_valid): + # GIVEN + transaction_id = "1234567890" + + create_checkout_information( + user=self.user, + transaction_id=transaction_id, + state=CheckoutState.ONGOING, + ) + + mock_is_signature_valid.return_value = True + + # WHEN + response_1 = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + headers={"Datatrans-Signature": ""}, + data={ + "transactionId": transaction_id, + "status": "failed", + "whatever": "1", + }, + ) + + response_2 = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + headers={"Datatrans-Signature": ""}, + data={ + "transactionId": transaction_id, + "status": "failed", + "whatever": "2", + }, + ) + + self.assertEqual(response_1.status_code, status.HTTP_200_OK) + self.assertEqual(response_2.status_code, status.HTTP_200_OK) + + # THEN + + self.assertEqual( + CheckoutInformation.objects.get( + transaction_id=transaction_id + ).webhook_history, + [ + { + "transactionId": transaction_id, + "status": "failed", + "whatever": "1", + }, + { + "transactionId": transaction_id, + "status": "failed", + "whatever": "2", + }, + ], + ) + + @patch("vbv_lernwelt.shop.views.is_signature_valid") + def test_webhook_failed(self, mock_is_signature_valid): + # GIVEN + transaction_id = "1234567890" + state_received = "failed" + + create_checkout_information( + user=self.user, + transaction_id=transaction_id, + state=CheckoutState.ONGOING, + ) + + mock_is_signature_valid.return_value = True + + # WHEN + response = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + headers={"Datatrans-Signature": ""}, + data={ + "transactionId": transaction_id, + "status": state_received, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + CheckoutInformation.objects.get(transaction_id=transaction_id).state, + CheckoutState.FAILED, + ) + + @patch("vbv_lernwelt.shop.views.is_signature_valid") + def test_webhook_cancelled(self, mock_is_signature_valid): + # GIVEN + transaction_id = "1234567890" + state_received = "canceled" + + create_checkout_information( # noqa + user=self.user, + transaction_id=transaction_id, + state=CheckoutState.ONGOING, + ) + + mock_is_signature_valid.return_value = True + + # WHEN + response = self.client.post( + path=reverse("shop-transaction-webhook"), + format="json", + headers={"Datatrans-Signature": ""}, + data={ + "transactionId": transaction_id, + "status": state_received, + }, + ) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + CheckoutInformation.objects.get(transaction_id=transaction_id).state, + CheckoutState.CANCELED, + ) diff --git a/server/vbv_lernwelt/shop/tests/test_invoice.py b/server/vbv_lernwelt/shop/tests/test_invoice.py new file mode 100644 index 00000000..538aeaf3 --- /dev/null +++ b/server/vbv_lernwelt/shop/tests/test_invoice.py @@ -0,0 +1,86 @@ +from datetime import date +from unittest.mock import create_autospec + +from django.test import TestCase + +from vbv_lernwelt.core.admin import User +from vbv_lernwelt.shop.invoice.abacus import AbacusInvoiceCreator +from vbv_lernwelt.shop.invoice.creator import Item +from vbv_lernwelt.shop.invoice.repositories import InvoiceRepository +from vbv_lernwelt.shop.models import CheckoutInformation + +USER_USERNAME = "testuser" +USER_EMAIL = "test@example.com" +USER_PASSWORD = "testpassword" + + +class InvoiceTestCase(TestCase): + def setUp(self) -> None: + self.user = User.objects.create_user( + username=USER_USERNAME, + email=USER_EMAIL, + password=USER_PASSWORD, + is_active=True, + ) + + def test_render_invoice(self): + # GIVEN + creator = AbacusInvoiceCreator(repository=create_autospec(InvoiceRepository)) + items = [Item(product_number="001", quantity=1, description="Test Item")] + customer_number = "12345" + order_date = date(2023, 1, 1) + reference_purchase_order = "PO12345678" + unic_id = "UNIC001" + + # WHEN + invoice_xml = creator.invoice_xml( + customer_number, + order_date, + reference_purchase_order, + unic_id, + items, + ) + + # THEN + assert "12345" in invoice_xml + assert "1" in invoice_xml + assert "001" in invoice_xml + assert "1" in invoice_xml + assert "Test Item" 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 "12345" in uploaded_invoice + assert "1" in uploaded_invoice + assert "001" in uploaded_invoice + assert "1" in uploaded_invoice + assert "Test Product Description" in uploaded_invoice diff --git a/server/vbv_lernwelt/shop/urls.py b/server/vbv_lernwelt/shop/urls.py new file mode 100644 index 00000000..c782ba37 --- /dev/null +++ b/server/vbv_lernwelt/shop/urls.py @@ -0,0 +1,22 @@ +from django.urls import path + +from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt +from vbv_lernwelt.shop.views import ( + checkout_vv, + get_billing_address, + transaction_webhook, + update_billing_address, +) + +urlpatterns = [ + path("billing-address/", get_billing_address, name="get-billing-address"), + path( + "billing-address/update/", update_billing_address, name="update-billing-address" + ), + path("vv/checkout/", checkout_vv, name="checkout-vv"), + path( + "transaction/webhook/", + django_view_authentication_exempt(transaction_webhook), + name="shop-transaction-webhook", + ), +] diff --git a/server/vbv_lernwelt/shop/views.py b/server/vbv_lernwelt/shop/views.py new file mode 100644 index 00000000..50f2169b --- /dev/null +++ b/server/vbv_lernwelt/shop/views.py @@ -0,0 +1,263 @@ +import structlog +from django.conf import settings +from django.http import JsonResponse +from rest_framework import status +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from sentry_sdk import capture_exception + +from vbv_lernwelt.course.consts import ( + COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_ID, + COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID, +) +from vbv_lernwelt.course.models import CourseSession, CourseSessionUser +from vbv_lernwelt.notify.email.email_services import EmailTemplate, send_email +from vbv_lernwelt.shop.const import ( + VV_DE_PRODUCT_SKU, + VV_FR_PRODUCT_SKU, + VV_IT_PRODUCT_SKU, +) +from vbv_lernwelt.shop.models import ( + BillingAddress, + CheckoutInformation, + CheckoutState, + Product, +) +from vbv_lernwelt.shop.serializers import BillingAddressSerializer +from vbv_lernwelt.shop.services import ( + datatrans_state_to_checkout_state, + get_payment_url, + init_transaction, + InitTransactionException, + is_signature_valid, +) + +logger = structlog.get_logger(__name__) + +PRODUCT_SKU_TO_COURSE = { + VV_DE_PRODUCT_SKU: COURSE_VERSICHERUNGSVERMITTLERIN_ID, + VV_FR_PRODUCT_SKU: COURSE_VERSICHERUNGSVERMITTLERIN_FR_ID, + VV_IT_PRODUCT_SKU: COURSE_VERSICHERUNGSVERMITTLERIN_IT_ID, +} + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def get_billing_address(request): + try: + billing_address = BillingAddress.objects.get(user=request.user) + data = BillingAddressSerializer(billing_address).data + except BillingAddress.DoesNotExist: + data = BillingAddressSerializer().data + data["first_name"] = request.user.first_name # noqa + data["last_name"] = request.user.last_name # noqa + + return Response(data) + + +@api_view(["PUT"]) +@permission_classes([IsAuthenticated]) +def update_billing_address(request): + try: + billing_address = BillingAddress.objects.get(user=request.user) + except BillingAddress.DoesNotExist: + billing_address = None + + serializer = BillingAddressSerializer( + billing_address, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save(user=request.user) + return Response(serializer.data) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +@api_view(["POST"]) +def transaction_webhook(request): + """IMPORTANT: This is not called for timed out transactions!""" + + logger.info("Webhook: Datatrans called transaction webhook", body=request.body) + + if not is_signature_valid( + signature=request.headers.get("Datatrans-Signature", ""), + payload=request.body, + ): + logger.warning("Datatrans Transaction Webhook: Invalid Signature -> Ignored") + return JsonResponse({"status": "invalid signature"}, status=400) + + transaction = request.data + transaction_id = transaction["transactionId"] + + # keep webhook history (for debugging) + checkout_info = CheckoutInformation.objects.get(transaction_id=transaction_id) + checkout_info.webhook_history.append(transaction) + checkout_info.save(update_fields=["webhook_history"]) + + # update checkout state + checkout_state = datatrans_state_to_checkout_state(transaction["status"]) + update_checkout_state(checkout_info=checkout_info, state=checkout_state) + + # handle paid + if checkout_state == CheckoutState.PAID: + create_vv_course_session_user(checkout_info=checkout_info) + + return JsonResponse({"status": "ok"}) + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def checkout_vv(request): + """ + Check-out for the Versicherungsvermittler products (vv-de, vv-fr, vv-it) + + IMPORTANT: Even if we have an already ONGOING checkout, + we create a new one! This might seem a bit unintuitive, + but it's the advised way to handle it by Datatrans: + + "Fehlverhalten des User können fast gar nicht abgefangen werden, + wichtig wäre aus eurer Sicht das ihr immer einen neuen INIT + schickt, wenn der User im Checkout ist und zum Beispiel + auf «Bezahlen» klickt. Um zum Beispiel White-screens + bei Browser Back redirections zu vermeiden." + + """ + sku = request.data["product"] + base_redirect_url = request.data["redirect_url"] + + logger.info(f"Checkout requested: sku={sku}", user_id=request.user.id) + + try: + product = Product.objects.get(sku=sku) + except Product.DoesNotExist: + return next_step_response( + url=checkout_error_url( + base_url=base_redirect_url, + product_sku=sku, + message=f"{sku}_product_sku_does_not_exist_needs_to_be_created_first", + ), + ) + + checkouts = CheckoutInformation.objects.filter( + user=request.user, + product_sku=sku, + ) + + # already paid successfully -> redirect to home + # any other case create a new checkout (see doc above) + if checkouts.filter(state=CheckoutState.PAID).exists(): + return next_step_response(url="/") + + try: + transaction_id = init_transaction( + user=request.user, + amount_chf_centimes=product.price, + redirect_url_success=checkout_success_url( + base_url=base_redirect_url, product_sku=sku + ), + redirect_url_error=checkout_error_url( + base_url=base_redirect_url, product_sku=sku + ), + redirect_url_cancel=checkout_cancel_url(base_redirect_url), + webhook_url=webhook_url(base_redirect_url), + ) + except InitTransactionException as e: + if not settings.DEBUG: + capture_exception(e) + return next_step_response( + url=checkout_error_url( + base_url=base_redirect_url, + product_sku=sku, + ), + ) + + CheckoutInformation.objects.create( + user=request.user, + state=CheckoutState.ONGOING, + transaction_id=transaction_id, + # product + product_sku=sku, + product_price=product.price, + product_name=product.name, + product_description=product.description, + # address + **request.data["address"], + ) + + return next_step_response(url=get_payment_url(transaction_id)) + + +def update_checkout_state(checkout_info: CheckoutInformation, state: CheckoutState): + checkout_info.state = state.value + checkout_info.save(update_fields=["state"]) + + +def send_vv_welcome_email(checkout_info: CheckoutInformation): + course_names = { + VV_DE_PRODUCT_SKU: "Versicherungsvermittler/-in (Deutsch)", + VV_FR_PRODUCT_SKU: "Intermédiaire d’assurance (Français)", + VV_IT_PRODUCT_SKU: "Intermediario/a assicurativo/a (Italiano)", + } + + send_email( + recipient_email=checkout_info.user.email, + template=EmailTemplate.WELCOME_MAIL_VV, + template_data={ + "course": course_names[checkout_info.product_sku], + "target_url": "https://my.vbv-afa.ch/", + }, + template_language=checkout_info.user.language, + fail_silently=True, + ) + + +def create_vv_course_session_user(checkout_info: CheckoutInformation): + logger.info("Creating VV course session user", user_id=checkout_info.user_id) + + _, created = CourseSessionUser.objects.get_or_create( + user=checkout_info.user, + role=CourseSessionUser.Role.MEMBER, + course_session=CourseSession.objects.filter( + course_id=PRODUCT_SKU_TO_COURSE[checkout_info.product_sku] + ).first(), + ) + + if created: + logger.info("VV course session user created", user_id=checkout_info.user_id) + send_vv_welcome_email(checkout_info) + + +def next_step_response( + url: str, +) -> JsonResponse: + return JsonResponse( + { + "next_step_url": url, + }, + ) + + +def webhook_url(base_url: str) -> str: + return f"{base_url}/api/shop/transaction/webhook/" + + +def checkout_error_url( + base_url: str, product_sku: str, message: str | None = None +) -> str: + url = f"{base_url}/onboarding/{product_sku}/checkout/address?error" + + if message: + url += f"&message={message}" + + return url + + +def checkout_cancel_url(base_url: str) -> str: + return f"{base_url}/" + + +def checkout_success_url(product_sku: str, base_url: str = "") -> str: + return f"{base_url}/onboarding/{product_sku}/checkout/complete" diff --git a/server/vbv_lernwelt/sso/client.py b/server/vbv_lernwelt/sso/client.py index 875bfec1..ea10b6bf 100644 --- a/server/vbv_lernwelt/sso/client.py +++ b/server/vbv_lernwelt/sso/client.py @@ -1,15 +1,5 @@ from authlib.integrations.django_client import OAuth -from django.conf import settings -# # https://docs.authlib.org/en/latest/client/frameworks.html#frameworks-clients oauth = OAuth() -oauth.register( - name=settings.OAUTH["client_name"], - client_id=settings.OAUTH["client_id"], - client_secret=settings.OAUTH["client_secret"], - request_token_url=None, - request_token_params=None, - authorize_params=settings.OAUTH["authorize_params"], - client_kwargs=settings.OAUTH["client_kwargs"], - server_metadata_url=settings.OAUTH["server_metadata_url"], -) +oauth.register(name="signup") +oauth.register(name="signin") diff --git a/server/vbv_lernwelt/sso/tests/__init__.py b/server/vbv_lernwelt/sso/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/sso/tests/test_sso_flow.py b/server/vbv_lernwelt/sso/tests/test_sso_flow.py new file mode 100644 index 00000000..31c2d4e7 --- /dev/null +++ b/server/vbv_lernwelt/sso/tests/test_sso_flow.py @@ -0,0 +1,356 @@ +import base64 +import json +import uuid +from unittest.mock import ANY, MagicMock, patch + +from authlib.integrations.base_client import OAuthError +from django.shortcuts import redirect +from django.test import override_settings, TestCase +from django.urls import reverse + +from vbv_lernwelt.core.models import User +from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN_ID +from vbv_lernwelt.course.creators.test_utils import ( + add_course_session_user, + create_course, + create_course_session, +) +from vbv_lernwelt.course.models import CourseSession, CourseSessionUser +from vbv_lernwelt.importer.services import create_or_update_user + + +def decoded_token(email, oid=None, given_name="Bobby", family_name="Table"): + return { + "email": email, + "oid": oid or uuid.uuid4(), + "given_name": given_name, + "family_name": family_name, + } + + +class TestSignInAuthorizeSSO(TestCase): + def setUp(self): + CourseSession.objects.all().delete() + User.objects.all().delete() + + @override_settings(OAUTH={"client_name": "mock"}) + @patch("vbv_lernwelt.sso.views.oauth") + @patch("vbv_lernwelt.sso.views.decode_jwt") + def test_authorize_first_time_uk(self, mock_decode_jwt, mock_oauth): + # GIVEN + email = "user@example.com" + token = decoded_token(email) + + mock_decode_jwt.return_value = token + mock_oauth.signin.authorize_access_token.return_value = { + "id_token": "" + } + + state = base64.urlsafe_b64encode( + json.dumps({"course": "uk", "next": "/shall-be-ignored"}).encode() + ).decode() + + # WHEN + response = self.client.get(reverse("sso:authorize"), {"state": state}) + + # THEN + self.assertEqual(302, response.status_code) + self.assertEqual("/onboarding/uk/account/create", response.url) # noqa + + user = User.objects.get(email=email) # noqa + self.assertIsNotNone(user) + + self.assertEqual(user.email, email) + self.assertEqual(user.first_name, token["given_name"]) + self.assertEqual(user.last_name, token["family_name"]) + self.assertEqual(user.sso_id, token["oid"]) + + @override_settings(OAUTH={"client_name": "mock"}) + @patch("vbv_lernwelt.sso.views.oauth") + @patch("vbv_lernwelt.sso.views.decode_jwt") + def test_authorize_first_time_vv(self, mock_decode_jwt, mock_oauth): + # GIVEN + email = "user@example.com" + token = decoded_token(email) + + mock_decode_jwt.return_value = token + mock_oauth.signin.authorize_access_token.return_value = { + "id_token": "" + } + + state = base64.urlsafe_b64encode( + json.dumps({"course": "vv-de", "next": "/shall-be-ignored"}).encode() + ).decode() + + # WHEN + response = self.client.get(reverse("sso:authorize"), {"state": state}) + + # THEN + self.assertEqual(302, response.status_code) + self.assertEqual("/onboarding/vv-de/account/create", response.url) # noqa + + user = User.objects.get(email=email) # noqa + self.assertIsNotNone(user) + + self.assertEqual(user.email, email) + self.assertEqual(user.first_name, token["given_name"]) + self.assertEqual(user.last_name, token["family_name"]) + self.assertEqual(user.sso_id, token["oid"]) + + @patch("vbv_lernwelt.sso.views.oauth") + def test_authorize_on_tampered_token(self, mock_oauth_service): + # GIVEN + mock_oauth_service.signin.authorize_access_token.side_effect = OAuthError() + + # WHEN + response = self.client.get(reverse("sso:authorize")) + + # THEN + self.assertEqual(302, response.status_code) + self.assertEqual("/", response.url) # noqa + + @override_settings(OAUTH={"client_name": "mock"}) + @patch("vbv_lernwelt.sso.views.oauth") + @patch("vbv_lernwelt.sso.views.decode_jwt") + def test_authorize_onboarded_uk(self, mock_decode_jwt, mock_oauth): + # GIVEN + email = "some@email.com" + token = decoded_token(email) + mock_decode_jwt.return_value = token + mock_oauth.signin.authorize_access_token.return_value = { + "id_token": "" + } + + # create a user that is already onboarded for UK + user = create_or_update_user( + email=email, + sso_id=str(token["oid"]), + first_name=token["given_name"], + last_name=token["family_name"], + ) + + course, _ = create_course("uk") + course_session = create_course_session(course, "UK", "2023") + add_course_session_user(course_session, user, CourseSessionUser.Role.MEMBER) + + self.assertIsNotNone(User.objects.get(email=email)) + + # WHEN + state = base64.urlsafe_b64encode(json.dumps({"course": "uk"}).encode()).decode() + response = self.client.get(reverse("sso:authorize"), {"state": state}) + + # THEN + self.assertEqual(302, response.status_code) + self.assertEqual("/", response.url) # noqa + + @override_settings(OAUTH={"client_name": "mock"}) + @patch("vbv_lernwelt.sso.views.oauth") + @patch("vbv_lernwelt.sso.views.decode_jwt") + def test_authorize_onboarded_vv(self, mock_decode_jwt, mock_oauth): + # GIVEN + email = "some@email.com" + token = decoded_token(email) + mock_decode_jwt.return_value = token + mock_oauth.signin.authorize_access_token.return_value = { + "id_token": "" + } + + # create a user that is already onboarded for UK + user = create_or_update_user( + email=email, + sso_id=str(token["oid"]), + first_name=token["given_name"], + last_name=token["family_name"], + ) + + course, _ = create_course(_id=COURSE_VERSICHERUNGSVERMITTLERIN_ID, title="VV") + course_session = create_course_session(course, "VV", "VV") + add_course_session_user(course_session, user, CourseSessionUser.Role.MEMBER) + + self.assertIsNotNone(User.objects.get(email=email)) + + # WHEN + state = base64.urlsafe_b64encode(json.dumps({"course": "vv"}).encode()).decode() + response = self.client.get(reverse("sso:authorize"), {"state": state}) + + # THEN + self.assertEqual(302, response.status_code) + self.assertEqual("/", response.url) # noqa + + @override_settings(OAUTH={"client_name": "mock"}) + @patch("vbv_lernwelt.sso.views.oauth") + @patch("vbv_lernwelt.sso.views.decode_jwt") + def test_authorize_next_url(self, mock_decode_jwt, mock_oauth): + # GIVEN + next_url = "/some/next/url" + + mock_decode_jwt.return_value = decoded_token("whatever@example.com") + mock_oauth.signin.authorize_access_token.return_value = { + "id_token": "" + } + + # WHEN + state = base64.urlsafe_b64encode( + json.dumps({"next": next_url}).encode() + ).decode() + + response = self.client.get(reverse("sso:authorize"), {"state": state}) + + # THEN + self.assertEqual(302, response.status_code) + self.assertEqual(next_url, response.url) # noqa + + +class TestSignIn(TestCase): + @override_settings(OAUTH_SIGNIN_REDIRECT_URI="/sso/callback") + @patch("vbv_lernwelt.sso.views.oauth") + def test_signin_with_course_param(self, mock_oauth): + # GIVEN + course_param = "vv-de" + + expected_state = {"course": course_param, "next": None} + expected_state_encoded = base64.urlsafe_b64encode( + json.dumps(expected_state).encode() + ).decode() + + mock_oauth.signin.authorize_redirect = MagicMock() + mock_oauth.signin.authorize_redirect.return_value = redirect( + "/just/here/to/return/a/redirect/object" + ) + + # WHEN + self.client.get( + reverse("sso:login"), + {"course": course_param}, + ) + + # THEN + mock_oauth.signin.authorize_redirect.assert_called_once_with( + ANY, + "/sso/callback", + state=expected_state_encoded, + ui_locales="de", + ) + + @override_settings(OAUTH_SIGNIN_REDIRECT_URI="/sso/callback") + @patch("vbv_lernwelt.sso.views.oauth") + def test_signin_with_state_param(self, mock_oauth): + # GIVEN + course = "vv-de" + + state_param = base64.urlsafe_b64encode( + json.dumps({"course": course, "next": None}).encode() + ).decode() + + expected_state = {"course": course, "next": None} + expected_state_encoded = base64.urlsafe_b64encode( + json.dumps(expected_state).encode() + ).decode() + + mock_oauth.signin.authorize_redirect = MagicMock() + mock_oauth.signin.authorize_redirect.return_value = redirect( + "/just/here/to/return/a/redirect/object" + ) + + # WHEN + self.client.get( + reverse("sso:login"), + {"state": state_param}, + ) + + # THEN + mock_oauth.signin.authorize_redirect.assert_called_once_with( + ANY, + "/sso/callback", + state=expected_state_encoded, + ui_locales="de", + ) + + @override_settings(OAUTH_SIGNIN_REDIRECT_URI="/sso/callback") + @patch("vbv_lernwelt.sso.views.oauth") + def test_signin_next_url(self, mock_oauth): + # GIVEN + next_url = "/some/next/url" + + expected_state = {"course": None, "next": next_url} + expected_state_encoded = base64.urlsafe_b64encode( + json.dumps(expected_state).encode() + ).decode() + + mock_oauth.signin.authorize_redirect = MagicMock() + mock_oauth.signin.authorize_redirect.return_value = redirect( + "/just/here/to/return/a/redirect/object" + ) + + # WHEN + self.client.get( + reverse("sso:login"), + {"next": next_url}, + ) + + # THEN + mock_oauth.signin.authorize_redirect.assert_called_once_with( + ANY, + "/sso/callback", + state=expected_state_encoded, + ui_locales=ANY, + ) + + @override_settings(OAUTH_SIGNIN_REDIRECT_URI="/sso/callback") + @patch("vbv_lernwelt.sso.views.oauth") + def test_signin_language(self, mock_oauth): + # GIVEN + language = "fr" + + mock_oauth.signin.authorize_redirect = MagicMock() + mock_oauth.signin.authorize_redirect.return_value = redirect( + "/just/here/to/return/a/redirect/object" + ) + + # WHEN + self.client.get( + reverse("sso:login"), + {"lang": language}, + ) + + # THEN + mock_oauth.signin.authorize_redirect.assert_called_once_with( + ANY, + "/sso/callback", + state=ANY, + ui_locales=language, + ) + + +class TestSignUp(TestCase): + @override_settings(OAUTH_SIGNUP_REDIRECT_URI="/sso/login") + @patch("vbv_lernwelt.sso.views.oauth") + def test_signup_with_course_param(self, mock_oauth): + # GIVEN + course_param = "vv-de" + next_param = "/some-next-url" + language = "fr" + + expected_state = {"course": course_param, "next": next_param} + expected_state_encoded = base64.urlsafe_b64encode( + json.dumps(expected_state).encode() + ).decode() + + mock_oauth.signup.authorize_redirect = MagicMock() + mock_oauth.signup.authorize_redirect.return_value = redirect( + "/just/here/to/return/a/redirect/object" + ) + + # WHEN + self.client.get( + reverse("sso:signup"), + {"course": course_param, "next": next_param, "lang": language}, + ) + + # THEN + mock_oauth.signup.authorize_redirect.assert_called_once_with( + ANY, + "/sso/login", + state=expected_state_encoded, + lang=language, + ) diff --git a/server/vbv_lernwelt/sso/urls.py b/server/vbv_lernwelt/sso/urls.py index d3bea976..18abdbf2 100644 --- a/server/vbv_lernwelt/sso/urls.py +++ b/server/vbv_lernwelt/sso/urls.py @@ -6,10 +6,11 @@ from . import views app_name = "sso" urlpatterns = [ - path(r"login/", django_view_authentication_exempt(views.login), name="login"), + path(r"login/", django_view_authentication_exempt(views.signin), name="login"), + path(r"signup/", django_view_authentication_exempt(views.signup), name="signup"), path( r"callback/", - django_view_authentication_exempt(views.authorize), + django_view_authentication_exempt(views.authorize_signin), name="authorize", ), ] diff --git a/server/vbv_lernwelt/sso/views.py b/server/vbv_lernwelt/sso/views.py index 957a1b8e..d48a019a 100644 --- a/server/vbv_lernwelt/sso/views.py +++ b/server/vbv_lernwelt/sso/views.py @@ -1,3 +1,6 @@ +import base64 +import json + import structlog as structlog from authlib.integrations.base_client import OAuthError from django.conf import settings @@ -5,53 +8,116 @@ from django.contrib.auth import login as dj_login from django.shortcuts import redirect from sentry_sdk import capture_exception +from vbv_lernwelt.core.models import User +from vbv_lernwelt.course.models import CourseSession +from vbv_lernwelt.course_session.utils import has_course_session_user_vv from vbv_lernwelt.importer.services import create_or_update_user from vbv_lernwelt.sso.client import oauth from vbv_lernwelt.sso.jwt import decode_jwt logger = structlog.get_logger(__name__) -OAUTH_FAIL_REDIRECT = "login-error" + +def signup(request): + course_param = request.GET.get("course") + next_param = request.GET.get("next") + + state_json = json.dumps({"course": course_param, "next": next_param}) + state_encoded = base64.urlsafe_b64encode(state_json.encode()).decode() + + redirect_uri = settings.OAUTH_SIGNUP_REDIRECT_URI + + logger.debug( + f"SSO Signup (course={course_param}, next={next_param})", + sso_signup_redirect_uri=redirect_uri, + ) + + return oauth.signup.authorize_redirect( + request, redirect_uri, state=state_encoded, lang=request.GET.get("lang", "de") + ) -def login(request): - oauth_client = oauth.create_client(settings.OAUTH["client_name"]) - redirect_uri = settings.OAUTH["local_redirect_uri"] - language = request.GET.get("lang", "de") - return oauth_client.authorize_redirect(request, redirect_uri, lang=language) +def signin(request): + """ + Called directly from the frontend AND as a redirect from signup! + """ - -def authorize(request): - try: - logger.debug(request, label="sso") - token = getattr(oauth, settings.OAUTH["client_name"]).authorize_access_token( - request + # redirect from signup + if "state" in request.GET: + state_decoded = json.loads( + base64.urlsafe_b64decode(request.GET.get("state").encode()).decode() ) - decoded_token = decode_jwt(token["id_token"]) - # logger.debug(label="sso", decoded_token=decoded_token) + state_course_param = state_decoded.get("course") + state_next_param = state_decoded.get("next") + else: + state_course_param = None + state_next_param = None + + course_param = request.GET.get("course", state_course_param) + next_param = request.GET.get("next", state_next_param) + + state_json = json.dumps({"course": course_param, "next": next_param}) + state_encoded = base64.urlsafe_b64encode(state_json.encode()).decode() + + redirect_uri = settings.OAUTH_SIGNIN_REDIRECT_URI + + logger.info( + f"SSO Login (course={course_param}, next={next_param})", + sso_login_redirect_uri=redirect_uri, + ) + + return oauth.signin.authorize_redirect( + request, + redirect_uri, + state=state_encoded, + ui_locales=request.GET.get("lang", "de"), + ) + + +def get_redirect_uri(user: User, course: str | None, next_url: str | None): + if course and course.startswith("vv") and not has_course_session_user_vv(user): + return redirect(f"/onboarding/{course}/account/create") + elif ( + course == "uk" + and not CourseSession.objects.filter(coursesessionuser__user=user).exists() + ): + return redirect("/onboarding/uk/account/create") + elif next_url: + return redirect(next_url) + + return redirect("/") + + +def authorize_signin(request): + try: + jwt_token = oauth.signin.authorize_access_token(request) except OAuthError as e: logger.error(e, exc_info=True, label="sso") if not settings.DEBUG: capture_exception(e) - return redirect(f"/{OAUTH_FAIL_REDIRECT}?state=someerror") # to be defined + return redirect("/") + + id_token = decode_jwt(jwt_token["id_token"]) + + state = json.loads( + base64.urlsafe_b64decode(request.GET.get("state").encode()).decode() + ) + + course = state.get("course") + next_url = state.get("next") + + logger.debug( + f"SSO Authorize (course={course}, next={next_url}", + sso_authorize_id_token=id_token, + ) - user_data = _user_data_from_token_data(decoded_token) user = create_or_update_user( - email=user_data.get("email").lower(), - sso_id=user_data.get("sso_id"), - first_name=user_data.get("first_name", ""), - last_name=user_data.get("last_name", ""), + email=id_token.get("email", ""), + sso_id=id_token.get("oid"), + first_name=id_token.get("given_name", ""), + last_name=id_token.get("family_name", ""), ) dj_login(request, user) - return redirect(f"/") - -def _user_data_from_token_data(token: dict) -> dict: - first_email = token.get("emails", [""])[0] - return { - "first_name": token.get("given_name", ""), - "last_name": token.get("family_name", ""), - "email": first_email, - "sso_id": token.get("oid"), - } + return get_redirect_uri(user=user, course=course, next_url=next_url) diff --git a/server/vbv_lernwelt/static/icons/icon-globe.svg b/server/vbv_lernwelt/static/icons/icon-globe.svg index 0cdad129..f775822d 100644 --- a/server/vbv_lernwelt/static/icons/icon-globe.svg +++ b/server/vbv_lernwelt/static/icons/icon-globe.svg @@ -1,3 +1,3 @@ - + diff --git a/server/vbv_lernwelt/static/icons/vbv_questionmark.svg b/server/vbv_lernwelt/static/icons/vbv_questionmark.svg new file mode 100644 index 00000000..04b77842 --- /dev/null +++ b/server/vbv_lernwelt/static/icons/vbv_questionmark.svg @@ -0,0 +1,5 @@ + + + diff --git a/trufflehog-exclude-patterns.txt b/trufflehog-exclude-patterns.txt index 7e7e012a..b580d11c 100644 --- a/trufflehog-exclude-patterns.txt +++ b/trufflehog-exclude-patterns.txt @@ -7,6 +7,7 @@ server/vbv_lernwelt/notify/email/email_services.py server/vbv_lernwelt/static/ server/vbv_lernwelt/media/ server/vbv_lernwelt/edoniq_test/certificates/test.key +server/vbv_lernwelt/shop/tests/test_datatrans_signature.py server/vbv_lernwelt/shop/tests/test_create_signature.py supabase.md scripts/supabase/init.sql