From ab3dcd378e548cd030046e3a94b6a8560be98100 Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Tue, 14 Nov 2023 12:38:53 +0100 Subject: [PATCH] feat: shop app; billing address apis --- server/config/settings/base.py | 1 + server/config/urls.py | 5 + server/vbv_lernwelt/payment/admin.py | 3 - server/vbv_lernwelt/payment/models.py | 3 - server/vbv_lernwelt/payment/views.py | 3 - .../vbv_lernwelt/{payment => shop}/README.md | 2 + .../{payment => shop}/__init__.py | 0 server/vbv_lernwelt/shop/admin.py | 22 ++++ server/vbv_lernwelt/{payment => shop}/apps.py | 4 +- .../shop/migrations/0001_initial.py | 76 +++++++++++++ .../{payment => shop}/migrations/__init__.py | 0 server/vbv_lernwelt/shop/models.py | 83 ++++++++++++++ server/vbv_lernwelt/shop/serializers.py | 23 ++++ .../vbv_lernwelt/{payment => shop}/tests.py | 0 server/vbv_lernwelt/shop/tests/__init__.py | 0 .../shop/tests/test_billing_address_api.py | 106 ++++++++++++++++++ server/vbv_lernwelt/shop/views.py | 36 ++++++ 17 files changed, 356 insertions(+), 11 deletions(-) delete mode 100644 server/vbv_lernwelt/payment/admin.py delete mode 100644 server/vbv_lernwelt/payment/models.py delete mode 100644 server/vbv_lernwelt/payment/views.py rename server/vbv_lernwelt/{payment => shop}/README.md (99%) rename server/vbv_lernwelt/{payment => shop}/__init__.py (100%) create mode 100644 server/vbv_lernwelt/shop/admin.py rename server/vbv_lernwelt/{payment => shop}/apps.py (60%) create mode 100644 server/vbv_lernwelt/shop/migrations/0001_initial.py rename server/vbv_lernwelt/{payment => shop}/migrations/__init__.py (100%) create mode 100644 server/vbv_lernwelt/shop/models.py create mode 100644 server/vbv_lernwelt/shop/serializers.py rename server/vbv_lernwelt/{payment => shop}/tests.py (100%) create mode 100644 server/vbv_lernwelt/shop/tests/__init__.py create mode 100644 server/vbv_lernwelt/shop/tests/test_billing_address_api.py create mode 100644 server/vbv_lernwelt/shop/views.py diff --git a/server/config/settings/base.py b/server/config/settings/base.py index b7ffbd2e..83fdfcee 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", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/server/config/urls.py b/server/config/urls.py index 3d6b1e32..50fddead 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -57,6 +57,7 @@ from vbv_lernwelt.importer.views import ( t2l_sync, ) from vbv_lernwelt.notify.views import email_notification_settings +from vbv_lernwelt.shop.views import get_billing_address, update_billing_address class SignedIntConverter(IntConverter): @@ -170,6 +171,10 @@ urlpatterns = [ path(r'api/core/edoniq-test/export-users-trainers/', export_students_and_trainers, name='edoniq_export_students_and_trainers'), + # shop + path('api/shop/billing-address/', get_billing_address, name='get-billing-address'), + path('api/shop/billing-address/update', update_billing_address, name='update-billing-address'), + # importer path( r"server/importer/coursesession-trainer-import/", diff --git a/server/vbv_lernwelt/payment/admin.py b/server/vbv_lernwelt/payment/admin.py deleted file mode 100644 index 8c38f3f3..00000000 --- a/server/vbv_lernwelt/payment/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/server/vbv_lernwelt/payment/models.py b/server/vbv_lernwelt/payment/models.py deleted file mode 100644 index 71a83623..00000000 --- a/server/vbv_lernwelt/payment/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/server/vbv_lernwelt/payment/views.py b/server/vbv_lernwelt/payment/views.py deleted file mode 100644 index 91ea44a2..00000000 --- a/server/vbv_lernwelt/payment/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/server/vbv_lernwelt/payment/README.md b/server/vbv_lernwelt/shop/README.md similarity index 99% rename from server/vbv_lernwelt/payment/README.md rename to server/vbv_lernwelt/shop/README.md index f7b59725..48efefbc 100644 --- a/server/vbv_lernwelt/payment/README.md +++ b/server/vbv_lernwelt/shop/README.md @@ -150,6 +150,8 @@ def webhook(): Checks the Datatrans-Signature header of the incoming request and validates the signature: https://api-reference.datatrans.ch/#section/Webhook/Webhook-signing """ + + # TODO Check the state here too! hmac_key = HMAC_KEY diff --git a/server/vbv_lernwelt/payment/__init__.py b/server/vbv_lernwelt/shop/__init__.py similarity index 100% rename from server/vbv_lernwelt/payment/__init__.py rename to server/vbv_lernwelt/shop/__init__.py diff --git a/server/vbv_lernwelt/shop/admin.py b/server/vbv_lernwelt/shop/admin.py new file mode 100644 index 00000000..9f8ed707 --- /dev/null +++ b/server/vbv_lernwelt/shop/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin + +from vbv_lernwelt.shop.models import CheckoutInformation + + +@admin.action(description="Create invoice for selected checkouts") +def generate_invoice(modeladmin, request, queryset): + pass + + +@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] diff --git a/server/vbv_lernwelt/payment/apps.py b/server/vbv_lernwelt/shop/apps.py similarity index 60% rename from server/vbv_lernwelt/payment/apps.py rename to server/vbv_lernwelt/shop/apps.py index ab29d312..c270114e 100644 --- a/server/vbv_lernwelt/payment/apps.py +++ b/server/vbv_lernwelt/shop/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class PaymentConfig(AppConfig): +class ShopConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "payment" + name = "vbv_lernwelt.shop" 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..99270190 --- /dev/null +++ b/server/vbv_lernwelt/shop/migrations/0001_initial.py @@ -0,0 +1,76 @@ +# Generated by Django 3.2.20 on 2023-11-14 10:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +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/payment/migrations/__init__.py b/server/vbv_lernwelt/shop/migrations/__init__.py similarity index 100% rename from server/vbv_lernwelt/payment/migrations/__init__.py rename to server/vbv_lernwelt/shop/migrations/__init__.py diff --git a/server/vbv_lernwelt/shop/models.py b/server/vbv_lernwelt/shop/models.py new file mode 100644 index 00000000..2691fdb4 --- /dev/null +++ b/server/vbv_lernwelt/shop/models.py @@ -0,0 +1,83 @@ +from django.db import models + + +class BillingAddress(models.Model): + """ + Draft of a billing address for a purchase from the shop. + """ + + user = models.ForeignKey("core.User", on_delete=models.CASCADE) + + # user + first_name = models.CharField(max_length=255, blank=True) + last_name = models.CharField(max_length=255, blank=True) + street_address = models.CharField(max_length=255, blank=True) + street_number_address = 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_address = models.CharField(max_length=255, blank=True) + company_street_number_address = 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) + + +SKUS = [("1", "VV")] + + +class Product(models.Model): + price = models.IntegerField() # 10_00 = 10.00 CHF + sku = models.CharField(max_length=255, unique=True, choices=SKUS) + name = models.CharField(max_length=255) + description = models.CharField(max_length=255) + + +class CheckoutInformation(models.Model): + """ + Immutable checkout information for a purchase from the shop. + """ + + user = models.ForeignKey("core.User", on_delete=models.PROTECT) + + # immutable product information at time of purchase + product_sku = models.CharField(max_length=255, choices=SKUS) + product_price = models.IntegerField() # 10_00 = 10.00 CHF + 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( + max_length=255, + choices=[ + ("initialized", "initialized"), + ("settled", "settled"), + ("canceled", "canceled"), + ("failed", "failed"), + ], + ) + + 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_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 (optional) + company_name = models.CharField(max_length=255, blank=True) + company_street_address = models.CharField(max_length=255, blank=True) + company_street_number_address = 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) diff --git a/server/vbv_lernwelt/shop/serializers.py b/server/vbv_lernwelt/shop/serializers.py new file mode 100644 index 00000000..da861fce --- /dev/null +++ b/server/vbv_lernwelt/shop/serializers.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +from .models import BillingAddress + + +class BillingAddressSerializer(serializers.ModelSerializer): + class Meta: + model = BillingAddress + fields = [ + "first_name", + "last_name", + "street_address", + "street_number_address", + "postal_code", + "city", + "country", + "company_name", + "company_street_address", + "company_street_number_address", + "company_postal_code", + "company_city", + "company_country", + ] diff --git a/server/vbv_lernwelt/payment/tests.py b/server/vbv_lernwelt/shop/tests.py similarity index 100% rename from server/vbv_lernwelt/payment/tests.py rename to server/vbv_lernwelt/shop/tests.py 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..3ea1a760 --- /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_address="123 Main St", + street_number_address="45A", + postal_code="12345", + city="Test City", + country="Test Country", + company_name="Test Company", + company_street_address="456 Company St", + company_street_number_address="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_address"], "123 Main St") + self.assertEqual(response.data["street_number_address"], "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_address"], "456 Company St") + self.assertEqual(response.data["company_street_number_address"], "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_address": "789 New St", + "street_number_address": "101C", + "postal_code": "54321", + "city": "New City", + "country": "New Country", + "company_name": "New Company", + "company_street_address": "789 Company St", + "company_street_number_address": "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_address, "789 New St") + self.assertEqual(updated_address.street_number_address, "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_address, "789 Company St") + self.assertEqual(updated_address.company_street_number_address, "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/views.py b/server/vbv_lernwelt/shop/views.py new file mode 100644 index 00000000..94755806 --- /dev/null +++ b/server/vbv_lernwelt/shop/views.py @@ -0,0 +1,36 @@ +from django.shortcuts import get_object_or_404 +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 vbv_lernwelt.shop.models import BillingAddress +from vbv_lernwelt.shop.serializers import BillingAddressSerializer + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def get_billing_address(request): + billing_address = get_object_or_404(BillingAddress, user=request.user) + serializer = BillingAddressSerializer(billing_address) + + return Response(serializer.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)