diff --git a/client/src/components/onboarding/DatatransCembraDeviceFingerprint.vue b/client/src/components/onboarding/DatatransCembraDeviceFingerprint.vue index 3ff87c27..6ccb9de2 100644 --- a/client/src/components/onboarding/DatatransCembraDeviceFingerprint.vue +++ b/client/src/components/onboarding/DatatransCembraDeviceFingerprint.vue @@ -43,5 +43,4 @@ export default { }; - + diff --git a/client/src/main.ts b/client/src/main.ts index 900b094d..f9124525 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1,4 +1,5 @@ import { i18nextInit } from "@/i18nextWrapper"; +import { generateLocalSessionKey } from "@/statistics"; import * as Sentry from "@sentry/vue"; import i18next from "i18next"; import I18NextVue from "i18next-vue"; @@ -9,7 +10,6 @@ import type { Router } from "vue-router"; import "../tailwind.css"; import App from "./App.vue"; import router from "./router"; -import { generateLocalSessionKey } from "@/statistics"; declare module "pinia" { export interface PiniaCustomProperties { diff --git a/server/config/urls.py b/server/config/urls.py index c105ca41..f956de90 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -69,7 +69,7 @@ from vbv_lernwelt.importer.views import ( from vbv_lernwelt.media_files.views import user_image from vbv_lernwelt.notify.views import email_notification_settings -from vbv_lernwelt.shop.datatrans_fake_server import ( +from vbv_lernwelt.shop.datatrans.datatrans_fake_server import ( fake_datatrans_api_view, fake_datatrans_pay_view, ) diff --git a/server/requirements/requirements-dev.txt b/server/requirements/requirements-dev.txt index 7312ba40..799067cf 100644 --- a/server/requirements/requirements-dev.txt +++ b/server/requirements/requirements-dev.txt @@ -576,6 +576,8 @@ typing-extensions==4.7.1 # wagtail-localize typing-inspect==0.9.0 # via libcst +ua-parser==0.18.0 + # via -r requirements.in ufmt==2.2.0 # via -r requirements-dev.in uritemplate==4.1.1 diff --git a/server/requirements/requirements.in b/server/requirements/requirements.in index 3b5ae91e..28ce1425 100644 --- a/server/requirements/requirements.in +++ b/server/requirements/requirements.in @@ -8,6 +8,7 @@ redis # https://github.com/redis/redis-py uvicorn[standard] # https://github.com/encode/uvicorn environs click +ua-parser # Django # ------------------------------------------------------------------------------ diff --git a/server/requirements/requirements.txt b/server/requirements/requirements.txt index 1987798b..783118be 100644 --- a/server/requirements/requirements.txt +++ b/server/requirements/requirements.txt @@ -324,6 +324,8 @@ typing-extensions==4.7.1 # dj-database-url # uvicorn # wagtail-localize +ua-parser==0.18.0 + # via -r requirements.in uritemplate==4.1.1 # via drf-spectacular urllib3==1.26.16 diff --git a/server/vbv_lernwelt/api/tests/test_profile.py b/server/vbv_lernwelt/api/tests/test_profile.py index bc31e8ec..bb8c3206 100644 --- a/server/vbv_lernwelt/api/tests/test_profile.py +++ b/server/vbv_lernwelt/api/tests/test_profile.py @@ -43,7 +43,8 @@ class ProfileViewTest(APITestCase): # THEN self.assertEqual(response.status_code, status.HTTP_200_OK) profile = response.data - self.assertEqual( + self.maxDiff = None + self.assertDictEqual( profile, { "id": str(self.user.id), @@ -62,6 +63,8 @@ class ProfileViewTest(APITestCase): "postal_code": "", "city": "", "country": None, + "phone_number": "", + "birth_date": None, "organisation_detail_name": "", "organisation_street": "", "organisation_street_number": "", diff --git a/server/vbv_lernwelt/core/admin.py b/server/vbv_lernwelt/core/admin.py index 8c44c349..e0c70dbb 100644 --- a/server/vbv_lernwelt/core/admin.py +++ b/server/vbv_lernwelt/core/admin.py @@ -2,7 +2,13 @@ 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 Country, JobLog, Organisation +from vbv_lernwelt.core.models import ( + Country, + ExternalApiRequestLog, + JobLog, + Organisation, + SecurityRequestResponseLog, +) from vbv_lernwelt.core.utils import pretty_print_json User = get_user_model() @@ -109,6 +115,95 @@ class JobLogAdmin(LogAdmin): return None +@admin.register(SecurityRequestResponseLog) +class SecurityRequestResponseLogAdmin(LogAdmin): + date_hierarchy = "created" + list_display = ( + "created", + "label", + "type", + "action", + "ref", + "request_username", + "request_full_path", + "request_method", + "response_status_code", + "request_client_ip", + "request_elapse_time", + "request_trace_id", + "session_key", + "user_agent_os", + "user_agent_browser", + ) + list_filter = [ + "label", + "type", + "action", + "request_full_path", + "request_method", + "response_status_code", + "user_agent_os", + "user_agent_browser", + ] + search_fields = [ + "request_username", + "request_full_path", + "request_client_ip", + "request_trace_id", + "additional_json_data", + ] + + def additional_json_data_pretty(self, instance): + return self.pretty_print_json(instance.additional_json_data) + + def get_readonly_fields(self, request, obj=None): + return super().get_readonly_fields(request, obj) + [ + "additional_json_data_pretty" + ] + + +@admin.register(ExternalApiRequestLog) +class ExternalApiRequestLogAdmin(LogAdmin): + date_hierarchy = "created" + list_display = ( + "created", + "api_request_verb", + "api_url", + "api_response_status_code", + "elapsed_time", + "request_trace_id", + "request_username", + ) + search_fields = [ + "request_username", + "request_trace_id", + "api_request_data", + "api_response_data", + "additional_json_data", + ] + list_filter = [ + "api_response_status_code", + "api_request_verb", + "api_url", + ] + + def api_request_data_pretty(self, instance): + return self.pretty_print_json(instance.api_request_data) + + def api_response_data_pretty(self, instance): + return self.pretty_print_json(instance.api_response_data) + + def additional_data_pretty(self, instance): + return self.pretty_print_json(instance.additional_data) + + def get_readonly_fields(self, request, obj=None): + return super().get_readonly_fields(request, obj) + [ + "api_request_data_pretty", + "api_response_data_pretty", + "additional_data_pretty", + ] + + @admin.register(Organisation) class OrganisationAdmin(admin.ModelAdmin): list_display = ( diff --git a/server/vbv_lernwelt/core/external_request_logger.py b/server/vbv_lernwelt/core/external_request_logger.py new file mode 100644 index 00000000..9c1cc2ea --- /dev/null +++ b/server/vbv_lernwelt/core/external_request_logger.py @@ -0,0 +1,85 @@ +import time +from functools import wraps + +import structlog + +from vbv_lernwelt.core.models import ExternalApiRequestLog + +logger = structlog.get_logger(__name__) + + +class ExternalApiRequestLogDecorator: + def __init__(self, base_url, store_request_data=True, store_response_data=True): + self.base_url = base_url + self.store_request_data = store_request_data + self.store_response_data = store_response_data + + def __call__(self, fn): + @wraps(fn) + def wrapper(*args, **kwargs): + store_request_data = self.store_request_data + store_response_data = self.store_response_data + + request_data = kwargs.get("json", None) + if not request_data: + request_data = kwargs.get("data", None) + if not request_data: + request_data = kwargs.get("params", None) + if not request_data: + request_data = args[2] if len(args) > 2 else {} + + url = args[1] + context_data = kwargs.get("context_data", {}) or {} + + if not store_request_data: + request_data = {"hidden": True} + + logger.debug( + "try to call subhub external api", + request_url=args[1], + request_data=request_data, + base_url=self.base_url, + label="subhub_external_communication", + ) + + log = ExternalApiRequestLog() + log.api_url = f"{self.base_url}{url}" + log.api_request_data = request_data + + log.request_trace_id = context_data.get("request_trace_id", "") + log.request_username = context_data.get("request_username", "") + log.additional_json_data = context_data + + # user = get_request_user() + # if user: + # log.user = user.username + + log.save() + + start = time.time() + api_response = fn(*args, **kwargs) + log.elapsed_time = round(time.time() - start, 3) + + log.api_request_verb = str(api_response.request.method) + log.api_response_status_code = api_response.status_code + if store_response_data: + log.api_response_data = str(api_response.text) + else: + log.api_response_data = "hidden" + + log.save() + + logger.info( + "call to subhub external api successful", + api_request_verb=log.api_request_verb, + api_url=log.api_url, + api_response_status_code=log.api_response_status_code, + api_time=log.elapsed_time, + api_request_data=log.api_request_data, + api_response_data=log.api_response_data, + label="subhub_external_communication", + ) + + return api_response + + return wrapper diff --git a/server/vbv_lernwelt/core/middleware/security.py b/server/vbv_lernwelt/core/middleware/security.py index 2279b8c3..85e0d9b3 100644 --- a/server/vbv_lernwelt/core/middleware/security.py +++ b/server/vbv_lernwelt/core/middleware/security.py @@ -1,12 +1,13 @@ +import time import uuid import structlog -from django.core.exceptions import PermissionDenied -from django.http import Http404 from ipware import get_client_ip from structlog.threadlocal import bind_threadlocal, clear_threadlocal +from ua_parser import user_agent_parser from vbv_lernwelt.core.models import SecurityRequestResponseLog +from vbv_lernwelt.importer.utils import try_parse_int logger = structlog.get_logger(__name__) @@ -30,30 +31,68 @@ class SecurityRequestResponseLoggingMiddleware: def create_logging_threadlocalbind(self, request): request_username = request.user.username if hasattr(request, "user") else "" - + request_trace_id = uuid.uuid4().hex bind_threadlocal( request_method=request.method, request_full_path=request.get_full_path(), request_username=request_username, request_client_ip=request.META.get("REMOTE_ADDR"), - request_trace_id=uuid.uuid4().hex, + request_trace_id=request_trace_id, ) - def create_database_security_request_response_log(self, request, response): + return request_trace_id + + def create_database_security_request_response_log( + self, request, response, elapsed_time, request_trace_id="" + ): try: entry = SecurityRequestResponseLog() entry.label = getattr(request, "security_request_logging", "") + entry.type = getattr(request, "type", "") entry.request_method = request.method - entry.request_full_path = request.get_full_path()[:255] + entry.request_full_path = request.get_full_path()[0:250] entry.request_username = ( request.user.username if hasattr(request, "user") else "" ) entry.request_client_ip = request.META.get("REMOTE_ADDR") - entry.request_scn = getattr(request, "scn", "") entry.response_status_code = response.status_code - entry.additional_json_data = getattr( - request, "log_additional_json_data", {} - ) + entry.request_trace_id = request_trace_id + entry.request_elapse_time = elapsed_time + entry.save() + + # save predefined fields + additional_json_data = getattr(request, "log_additional_json_data", {}) + + entry.category = additional_json_data.pop("category", "")[0:255].strip() + entry.action = additional_json_data.pop("action", "")[0:255].strip() + entry.name = additional_json_data.pop("name", "")[0:255].strip() + entry.ref = additional_json_data.pop("ref", "")[0:255].strip() + entry.local_url = additional_json_data.pop("local_url", "")[0:255].strip() + + entry.session_key = additional_json_data.pop("session_key", "")[ + 0:255 + ].strip() + _, value = try_parse_int(additional_json_data.pop("value", 0), 0) + entry.value = value + entry.save() + + user_agent = request.headers.get("User-Agent", "") + entry.user_agent = user_agent[0:4096].strip() + try: + ua_parsed = user_agent_parser.Parse(user_agent) + entry.user_agent_parsed = user_agent_parser.Parse(user_agent) + entry.user_agent_os = ua_parsed.get("os").get("family") + entry.user_agent_browser = ua_parsed.get("user_agent").get("family") + # pylint: disable=broad-except + except Exception as e: + logger.warning( + "error while parsing user agent", + label="analytics", + user_agent=user_agent, + error=str(e), + ) + + entry.additional_json_data = additional_json_data entry.save() @@ -63,18 +102,32 @@ class SecurityRequestResponseLoggingMiddleware: def log_request_response(self, request): clear_threadlocal() - self.create_logging_threadlocalbind(request) + request_trace_id = self.create_logging_threadlocalbind(request) + request.request_trace_id = request_trace_id logger.info( "url access initialized", label="security", ) + start_time = time.time() response = self.get_response(request) + elapsed_time = round(time.time() - start_time, 3) - security_request_logging = getattr(request, "security_request_logging", None) - if security_request_logging: - self.create_database_security_request_response_log(request, response) + try: + security_request_logging = getattr( + request, "security_request_logging", None + ) + if security_request_logging: + self.create_database_security_request_response_log( + request, + response, + elapsed_time=elapsed_time, + request_trace_id=request_trace_id, + ) + # pylint: disable=broad-except + except Exception: + logger.warn("could not create db entry", label="security", exc_info=True) logger.info( "url access finished", @@ -82,6 +135,7 @@ class SecurityRequestResponseLoggingMiddleware: response_status_code=response.status_code, request_ratelimited=getattr(request, "limited", False), request_finished=True, + elapsed_time=elapsed_time, ) clear_threadlocal() @@ -90,18 +144,3 @@ class SecurityRequestResponseLoggingMiddleware: def __call__(self, request): return self.log_request_response(request) - - def process_exception(self, request, exception): - if isinstance(exception, (Http404, PermissionDenied)): - # We don't log an exception here, and we don't set that we handled - # an error as we want the standard `request_finished` log message - # to be emitted. - return - - self._raised_exception = True - - logger.exception( - "request_failed", - label="security", - response_status_code=500, - ) diff --git a/server/vbv_lernwelt/core/migrations/0010_auto_20240620_1625.py b/server/vbv_lernwelt/core/migrations/0010_auto_20240620_1625.py index dfafcf04..72af3d36 100644 --- a/server/vbv_lernwelt/core/migrations/0010_auto_20240620_1625.py +++ b/server/vbv_lernwelt/core/migrations/0010_auto_20240620_1625.py @@ -4,20 +4,19 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('core', '0009_country_refactor'), + ("core", "0009_country_refactor"), ] operations = [ migrations.AddField( - model_name='user', - name='birth_date', + model_name="user", + name="birth_date", field=models.DateField(blank=True, null=True), ), migrations.AddField( - model_name='user', - name='phone_number', - field=models.CharField(blank=True, default='', max_length=255), + model_name="user", + name="phone_number", + field=models.CharField(blank=True, default="", max_length=255), ), ] diff --git a/server/vbv_lernwelt/core/migrations/0011_delete_securityrequestresponselog.py b/server/vbv_lernwelt/core/migrations/0011_delete_securityrequestresponselog.py new file mode 100644 index 00000000..61f01358 --- /dev/null +++ b/server/vbv_lernwelt/core/migrations/0011_delete_securityrequestresponselog.py @@ -0,0 +1,15 @@ +# Generated by Django 3.2.20 on 2024-06-21 13:10 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0010_auto_20240620_1625"), + ] + + operations = [ + migrations.DeleteModel( + name="SecurityRequestResponseLog", + ), + ] diff --git a/server/vbv_lernwelt/core/migrations/0012_auto_20240621_1626.py b/server/vbv_lernwelt/core/migrations/0012_auto_20240621_1626.py new file mode 100644 index 00000000..25b1fd45 --- /dev/null +++ b/server/vbv_lernwelt/core/migrations/0012_auto_20240621_1626.py @@ -0,0 +1,126 @@ +# Generated by Django 3.2.20 on 2024-06-21 14:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0011_delete_securityrequestresponselog"), + ] + + operations = [ + migrations.CreateModel( + name="ExternalApiRequestLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("api_url", models.TextField(blank=True, default="")), + ("api_request_data", models.JSONField(blank=True, default=dict)), + ( + "api_request_verb", + models.CharField(blank=True, default="", max_length=255), + ), + ("api_response_status_code", models.IntegerField(default=0)), + ("api_response_data", models.TextField(blank=True, default="")), + ( + "request_username", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "request_trace_id", + models.CharField(blank=True, default="", max_length=255), + ), + ("elapsed_time", models.FloatField(default=0)), + ("additional_json_data", models.JSONField(blank=True, default=dict)), + ], + ), + migrations.CreateModel( + name="SecurityRequestResponseLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("label", models.CharField(blank=True, default="", max_length=255)), + ("type", models.CharField(blank=True, default="", max_length=255)), + ( + "request_trace_id", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "request_method", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "request_full_path", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "request_username", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "request_client_ip", + models.CharField(blank=True, default="", max_length=255), + ), + ("request_elapse_time", models.FloatField(default=0)), + ( + "response_status_code", + models.CharField(blank=True, default="", max_length=255), + ), + ("category", models.CharField(blank=True, default="", max_length=255)), + ("action", models.CharField(blank=True, default="", max_length=255)), + ("name", models.CharField(blank=True, default="", max_length=255)), + ("ref", models.CharField(blank=True, default="", max_length=255)), + ("value", models.IntegerField(default=0)), + ("local_url", models.CharField(blank=True, default="", max_length=255)), + ( + "session_key", + models.CharField(blank=True, default="", max_length=255), + ), + ("user_agent", models.TextField(blank=True, default="")), + ("user_agent_parsed", models.JSONField(blank=True, default=dict)), + ( + "user_agent_os", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "user_agent_browser", + models.CharField(blank=True, default="", max_length=255), + ), + ("additional_json_data", models.JSONField(blank=True, default=dict)), + ], + ), + migrations.AddIndex( + model_name="securityrequestresponselog", + index=models.Index(fields=["type"], name="core_securi_type_009727_idx"), + ), + migrations.AddIndex( + model_name="securityrequestresponselog", + index=models.Index(fields=["label"], name="core_securi_label_dd1821_idx"), + ), + migrations.AddIndex( + model_name="securityrequestresponselog", + index=models.Index( + fields=["category"], name="core_securi_categor_1b776c_idx" + ), + ), + migrations.AddIndex( + model_name="securityrequestresponselog", + index=models.Index(fields=["action"], name="core_securi_action_5df089_idx"), + ), + ] diff --git a/server/vbv_lernwelt/core/models.py b/server/vbv_lernwelt/core/models.py index d1af6853..f1ee630d 100644 --- a/server/vbv_lernwelt/core/models.py +++ b/server/vbv_lernwelt/core/models.py @@ -154,16 +154,63 @@ class User(AbstractUser): class SecurityRequestResponseLog(models.Model): - label = models.CharField(max_length=255, blank=True, default="") + created = models.DateTimeField(auto_now_add=True) + label = models.CharField(max_length=255, blank=True, default="") + type = models.CharField(max_length=255, blank=True, default="") + + request_trace_id = models.CharField(max_length=255, blank=True, default="") request_method = models.CharField(max_length=255, blank=True, default="") request_full_path = models.CharField(max_length=255, blank=True, default="") request_username = models.CharField(max_length=255, blank=True, default="") request_client_ip = models.CharField(max_length=255, blank=True, default="") + request_elapse_time = models.FloatField(default=0) response_status_code = models.CharField(max_length=255, blank=True, default="") - additional_json_data = JSONField(default=dict, blank=True) + category = models.CharField(max_length=255, blank=True, default="") + action = models.CharField(max_length=255, blank=True, default="") + name = models.CharField(max_length=255, blank=True, default="") + ref = models.CharField(max_length=255, blank=True, default="") + value = models.IntegerField(default=0) + local_url = models.CharField(max_length=255, blank=True, default="") + + session_key = models.CharField(max_length=255, blank=True, default="") + + user_agent = models.TextField(blank=True, default="") + user_agent_parsed = models.JSONField(blank=True, default=dict) + user_agent_os = models.CharField(max_length=255, blank=True, default="") + user_agent_browser = models.CharField(max_length=255, blank=True, default="") + + additional_json_data = models.JSONField(default=dict, blank=True) + + class Meta: + indexes = [ + models.Index(fields=["type"]), + models.Index(fields=["label"]), + models.Index(fields=["category"]), + models.Index(fields=["action"]), + ] + + +class ExternalApiRequestLog(models.Model): + created = models.DateTimeField(auto_now_add=True) + + api_url = models.TextField(blank=True, default="") + api_request_data = models.JSONField(default=dict, blank=True) + + api_request_verb = models.CharField(max_length=255, blank=True, default="") + api_response_status_code = models.IntegerField(default=0) + api_response_data = models.TextField(blank=True, default="") + + request_username = models.CharField(max_length=255, blank=True, default="") + request_trace_id = models.CharField(max_length=255, blank=True, default="") + elapsed_time = models.FloatField(default=0) + + additional_json_data = models.JSONField(default=dict, blank=True) + + def __str__(self): + return f"{self.api_request_verb} {self.api_response_status_code} {self.api_url}" class JobLog(models.Model): diff --git a/server/vbv_lernwelt/shop/datatrans/__init__.py b/server/vbv_lernwelt/shop/datatrans/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/shop/datatrans/datatrans_api_client.py b/server/vbv_lernwelt/shop/datatrans/datatrans_api_client.py new file mode 100644 index 00000000..c4c7a828 --- /dev/null +++ b/server/vbv_lernwelt/shop/datatrans/datatrans_api_client.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- + +import requests +import structlog +from django.conf import settings + +from vbv_lernwelt.core.external_request_logger import ExternalApiRequestLogDecorator + +logger = structlog.getLogger(__name__) + + +class DatatransApiClient: + def __init__(self): + self.base_url = settings.DATATRANS_API_ENDPOINT + + @ExternalApiRequestLogDecorator( + base_url=settings.DATATRANS_API_ENDPOINT, + ) + def _get_request(self, url, params, headers=None, context_data=None): + complete_url = self.base_url + url + return requests.get(complete_url, params=params, headers=headers) + + @ExternalApiRequestLogDecorator( + base_url=settings.DATATRANS_API_ENDPOINT, + ) + def _post_request(self, url, json, headers=None, context_data=None): + complete_url = self.base_url + url + return requests.post(complete_url, json=json, headers=headers) + + def post_initialize_transactions( + self, json_payload: dict, context_data: dict = None + ): + url = "/v1/transactions" + return self._post_request( + url, + json=json_payload, + headers={ + "Authorization": f"Basic {settings.DATATRANS_BASIC_AUTH_KEY}", + "Content-Type": "application/json", + }, + context_data=context_data, + ) diff --git a/server/vbv_lernwelt/shop/datatrans_fake_server.py b/server/vbv_lernwelt/shop/datatrans/datatrans_fake_server.py similarity index 100% rename from server/vbv_lernwelt/shop/datatrans_fake_server.py rename to server/vbv_lernwelt/shop/datatrans/datatrans_fake_server.py diff --git a/server/vbv_lernwelt/shop/migrations/0015_cembra_fields.py b/server/vbv_lernwelt/shop/migrations/0015_cembra_fields.py index 06fa2a94..df6a8601 100644 --- a/server/vbv_lernwelt/shop/migrations/0015_cembra_fields.py +++ b/server/vbv_lernwelt/shop/migrations/0015_cembra_fields.py @@ -4,40 +4,39 @@ from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('shop', '0014_checkoutinformation_abacus_ssh_upload_done'), + ("shop", "0014_checkoutinformation_abacus_ssh_upload_done"), ] operations = [ migrations.AddField( - model_name='checkoutinformation', - name='birth_date', + model_name="checkoutinformation", + name="birth_date", field=models.DateField(blank=True, null=True), ), migrations.AddField( - model_name='checkoutinformation', - name='cembra_invoice', + model_name="checkoutinformation", + name="cembra_invoice", field=models.BooleanField(default=False), ), migrations.AddField( - model_name='checkoutinformation', - name='device_fingerprint_session_key', - field=models.CharField(blank=True, default='', max_length=255), + model_name="checkoutinformation", + name="device_fingerprint_session_key", + field=models.CharField(blank=True, default="", max_length=255), ), migrations.AddField( - model_name='checkoutinformation', - name='email', - field=models.CharField(blank=True, default='', max_length=255), + model_name="checkoutinformation", + name="email", + field=models.CharField(blank=True, default="", max_length=255), ), migrations.AddField( - model_name='checkoutinformation', - name='ip_address', - field=models.CharField(blank=True, default='', max_length=255), + model_name="checkoutinformation", + name="ip_address", + field=models.CharField(blank=True, default="", max_length=255), ), migrations.AddField( - model_name='checkoutinformation', - name='phone_number', - field=models.CharField(blank=True, default='', max_length=255), + model_name="checkoutinformation", + name="phone_number", + field=models.CharField(blank=True, default="", max_length=255), ), ] diff --git a/server/vbv_lernwelt/shop/services.py b/server/vbv_lernwelt/shop/services.py index 20c1aee8..a763c899 100644 --- a/server/vbv_lernwelt/shop/services.py +++ b/server/vbv_lernwelt/shop/services.py @@ -7,11 +7,36 @@ import structlog from django.conf import settings from vbv_lernwelt.core.admin import User +from vbv_lernwelt.shop.datatrans.datatrans_api_client import DatatransApiClient from vbv_lernwelt.shop.models import CheckoutState logger = structlog.get_logger(__name__) +def create_context_data_log( + request, + action: str, +): + request._request.security_request_logging = "shop_view" + request._request.type = "datatrans" + request_trace_id = getattr(request._request, "request_trace_id", "") + + context_data = { + "action": action, + "session_key": (request.data.get("session_key", "") or "")[0:255].strip(), + "ref": (request.data.get("ref", "") or "")[0:255].strip(), + "request_trace_id": (request_trace_id or "")[0:255].strip(), + "request_username": ( + request._request.user.username if hasattr(request._request, "user") else "" + ), + } + + request._request.log_additional_json_data = context_data + + log = logger.bind(label="shop", **context_data) + return context_data, log + + class InitTransactionException(Exception): pass @@ -54,6 +79,7 @@ def init_datatrans_transaction( webhook_url: str, datatrans_customer_data: dict = None, datatrans_int_data: dict = None, + context_data: dict = None, ): payload = { # We use autoSettle=True, so that we don't have to settle the transaction: @@ -82,13 +108,9 @@ def init_datatrans_transaction( 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", - }, + api_client = DatatransApiClient() + response = api_client.post_initialize_transactions( + json_payload=payload, context_data=context_data ) if response.status_code == 201: diff --git a/server/vbv_lernwelt/shop/tests/test_checkout_api.py b/server/vbv_lernwelt/shop/tests/test_checkout_api.py index 02228de8..efbee5da 100644 --- a/server/vbv_lernwelt/shop/tests/test_checkout_api.py +++ b/server/vbv_lernwelt/shop/tests/test_checkout_api.py @@ -83,13 +83,22 @@ class CheckoutAPITestCase(APITestCase): self.assertEqual(ci.state, "ongoing") self.assertEqual(ci.transaction_id, "1234567890") - 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/", + mock_init_transaction.assert_called_once() + call_kwargs = mock_init_transaction.call_args[1] + print(call_kwargs) + self.assertEqual(call_kwargs["user"], self.user) + self.assertEqual(call_kwargs["amount_chf_centimes"], 324_30) + self.assertEqual( + call_kwargs["redirect_url_success"], + f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/complete", + ) + self.assertEqual( + call_kwargs["redirect_url_error"], + f"{REDIRECT_URL}/onboarding/{VV_DE_PRODUCT_SKU}/checkout/address?error", + ) + self.assertEqual(call_kwargs["redirect_url_cancel"], f"{REDIRECT_URL}/") + self.assertEqual( + call_kwargs["webhook_url"], f"{REDIRECT_URL}/api/shop/transaction/webhook/" ) @patch("vbv_lernwelt.shop.views.init_datatrans_transaction") diff --git a/server/vbv_lernwelt/shop/tests/test_datatrans_service.py b/server/vbv_lernwelt/shop/tests/test_datatrans_service.py index 9d3aca8a..08de6287 100644 --- a/server/vbv_lernwelt/shop/tests/test_datatrans_service.py +++ b/server/vbv_lernwelt/shop/tests/test_datatrans_service.py @@ -48,26 +48,11 @@ class DatatransServiceTest(TestCase): 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", - }, - ) + mock_post.assert_called_once() + call_kwargs = mock_post.call_args[1] + print(call_kwargs) + self.assertEqual(call_kwargs["json"]["autoSettle"], True) + self.assertEqual(call_kwargs["json"]["amount"], 324_30) @patch("vbv_lernwelt.shop.services.requests.post") def test_init_transaction_500(self, mock_post): diff --git a/server/vbv_lernwelt/shop/views.py b/server/vbv_lernwelt/shop/views.py index 485afbaa..309fb92f 100644 --- a/server/vbv_lernwelt/shop/views.py +++ b/server/vbv_lernwelt/shop/views.py @@ -16,6 +16,7 @@ from vbv_lernwelt.shop.const import ( ) from vbv_lernwelt.shop.models import CheckoutInformation, CheckoutState, Product from vbv_lernwelt.shop.services import ( + create_context_data_log, datatrans_state_to_checkout_state, get_payment_url, init_datatrans_transaction, @@ -82,10 +83,12 @@ def checkout_vv(request): bei Browser Back redirections zu vermeiden." """ + context_data, log = create_context_data_log(request, "checkout_vv") + sku = request.data["product"] base_redirect_url = request.data["redirect_url"] - logger.info(f"Checkout requested: sku", user_id=request.user.id, sku=sku) + log.info(f"Checkout requested: sku", user_id=request.user.id, sku=sku) try: product = Product.objects.get(sku=sku) @@ -116,7 +119,6 @@ def checkout_vv(request): datatrans_customer_data = None datatrans_int_data = None if with_cembra_invoice: - if "fakeapi" not in settings.DATATRANS_API_ENDPOINT: request.user.set_increment_abacus_debitor_number() @@ -155,9 +157,11 @@ def checkout_vv(request): webhook_url=webhook_url(base_redirect_url), datatrans_customer_data=datatrans_customer_data, datatrans_int_data=datatrans_int_data, + context_data=context_data, ) except InitTransactionException as e: if not settings.DEBUG: + log.error("Transaction initiation failed", exc_info=True, error=str(e)) capture_exception(e) return next_step_response( url=checkout_error_url( @@ -190,7 +194,9 @@ def checkout_vv(request): email=email, ip_address=ip_address, cembra_invoice=with_cembra_invoice, - device_fingerprint_session_key=request.data["device_fingerprint_session_key"], + device_fingerprint_session_key=request.data.get( + "device_fingerprint_session_key", "" + ), # address **request.data["address"], )