feat: shop app; billing address apis
This commit is contained in:
parent
3644a0d77d
commit
ab3dcd378e
|
|
@ -131,6 +131,7 @@ LOCAL_APPS = [
|
||||||
"vbv_lernwelt.importer",
|
"vbv_lernwelt.importer",
|
||||||
"vbv_lernwelt.edoniq_test",
|
"vbv_lernwelt.edoniq_test",
|
||||||
"vbv_lernwelt.course_session_group",
|
"vbv_lernwelt.course_session_group",
|
||||||
|
"vbv_lernwelt.shop",
|
||||||
]
|
]
|
||||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ from vbv_lernwelt.importer.views import (
|
||||||
t2l_sync,
|
t2l_sync,
|
||||||
)
|
)
|
||||||
from vbv_lernwelt.notify.views import email_notification_settings
|
from vbv_lernwelt.notify.views import email_notification_settings
|
||||||
|
from vbv_lernwelt.shop.views import get_billing_address, update_billing_address
|
||||||
|
|
||||||
|
|
||||||
class SignedIntConverter(IntConverter):
|
class SignedIntConverter(IntConverter):
|
||||||
|
|
@ -170,6 +171,10 @@ urlpatterns = [
|
||||||
path(r'api/core/edoniq-test/export-users-trainers/', export_students_and_trainers,
|
path(r'api/core/edoniq-test/export-users-trainers/', export_students_and_trainers,
|
||||||
name='edoniq_export_students_and_trainers'),
|
name='edoniq_export_students_and_trainers'),
|
||||||
|
|
||||||
|
# shop
|
||||||
|
path('api/shop/billing-address/', get_billing_address, name='get-billing-address'),
|
||||||
|
path('api/shop/billing-address/update', update_billing_address, name='update-billing-address'),
|
||||||
|
|
||||||
# importer
|
# importer
|
||||||
path(
|
path(
|
||||||
r"server/importer/coursesession-trainer-import/",
|
r"server/importer/coursesession-trainer-import/",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
# Create your models here.
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
from django.shortcuts import render
|
|
||||||
|
|
||||||
# Create your views here.
|
|
||||||
|
|
@ -150,6 +150,8 @@ def webhook():
|
||||||
Checks the Datatrans-Signature header of the incoming request and validates the signature:
|
Checks the Datatrans-Signature header of the incoming request and validates the signature:
|
||||||
https://api-reference.datatrans.ch/#section/Webhook/Webhook-signing
|
https://api-reference.datatrans.ch/#section/Webhook/Webhook-signing
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# TODO Check the state here too!
|
||||||
|
|
||||||
hmac_key = HMAC_KEY
|
hmac_key = HMAC_KEY
|
||||||
|
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class PaymentConfig(AppConfig):
|
class ShopConfig(AppConfig):
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = "payment"
|
name = "vbv_lernwelt.shop"
|
||||||
|
|
@ -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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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/")
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue