diff --git a/accounts/urls.py b/accounts/urls.py index a93e3b3..9004df0 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -18,6 +18,7 @@ from django.conf.urls.static import static from django.urls import path, include from django.contrib import admin +from apps.transactions.views import TransactionList, TransactionDetail, TransactionPay, TransactionReceipt from apps.users.views import UserListView, UserDetailView, AccountView, RequestOTPView, ChangePasswordView from django.contrib.auth import urls as auth_urls @@ -32,6 +33,11 @@ urlpatterns = [ path('account/', AccountView.as_view(), name='account'), path('request_otp/', RequestOTPView.as_view(), name='register'), path('change_password/', ChangePasswordView.as_view(), name='change_password'), + + path('transactions/', TransactionList.as_view(), name='transactions'), + path('transactions/', TransactionDetail.as_view(), name='transaction_detail'), + path('transactions//pay', TransactionPay.as_view(), name='transaction_pay'), + path('transactions//receipt', TransactionReceipt.as_view(), name='transaction_receipt'), ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/apps/transactions/__init__.py b/apps/transactions/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/apps/transactions/admin.py b/apps/transactions/admin.py new file mode 100755 index 0000000..c6dec7d --- /dev/null +++ b/apps/transactions/admin.py @@ -0,0 +1,19 @@ +from django.contrib import admin +from .models import Transaction + + +class TransactionAdmin(admin.ModelAdmin): + fields = [ + 'code', + 'amount', + 'payer', + 'payee', + 'create', + 'last_update', + 'state', + 'delay' + ] + readonly_fields = ['last_update', 'create'] + + +admin.site.register(Transaction, TransactionAdmin) diff --git a/apps/transactions/apps.py b/apps/transactions/apps.py new file mode 100755 index 0000000..1562eb2 --- /dev/null +++ b/apps/transactions/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TransactionsConfig(AppConfig): + name = 'apps.transactions' diff --git a/apps/transactions/migrations/0001_initial.py b/apps/transactions/migrations/0001_initial.py new file mode 100644 index 0000000..b4f3800 --- /dev/null +++ b/apps/transactions/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 3.0 on 2019-12-06 20:24 + +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='Transaction', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.IntegerField(verbose_name='code')), + ('amount', models.IntegerField(verbose_name='amount')), + ('delay', models.IntegerField(blank=True, default=0)), + ('state', models.IntegerField(choices=[(1, 'created'), (2, 'delayed'), (3, 'pending'), (4, 'failed'), (5, 'done')], default=1)), + ('create', models.DateTimeField(auto_now=True, null=True, verbose_name='create')), + ('last_update', models.DateTimeField(auto_now=True, null=True, verbose_name='last update')), + ('detail_id', models.IntegerField(blank=True, null=True, unique=True)), + ('payee', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='receipts', to=settings.AUTH_USER_MODEL)), + ('payer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/apps/transactions/migrations/__init__.py b/apps/transactions/migrations/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/apps/transactions/models.py b/apps/transactions/models.py new file mode 100755 index 0000000..c647e6e --- /dev/null +++ b/apps/transactions/models.py @@ -0,0 +1,132 @@ +import datetime +import random + +from django.db import models +from django.db.models import F +from django.utils.translation import gettext_lazy as _ +from apps.users.models import User +from django.db import transaction +from model_utils.choices import Choices +from rest_framework.exceptions import APIException, ValidationError + + +class TransactionManager(models.Manager): + def create_transaction(self, + amount, + payee: User, + payer: User = None, + delay=0, + detail_id=None, + state=None): + if not (isinstance(payer, User) or payer is None): + raise ValidationError('payer type not valid') + if not isinstance(payee, User): + raise ValidationError('payee type not valid') + if not amount > 0: + raise ValidationError('amount should be greater than zero') + if not delay >= 0: + raise ValidationError('delay should be greater than equal zero') + + transaction = self.model(payer=payer, + payee=payee, + amount=amount, + delay=delay, + detail_id=detail_id + ) + if state: + transaction.state = state + + transaction.set_code() + transaction.save() + return transaction + + +class Transaction(models.Model): + STATE_CHOICES = Choices( + (1, 'created', 'created'), + (2, 'delayed', 'delayed'), + (3, 'pending', 'pending'), + (4, 'failed', 'failed'), + (5, 'done', 'done'), + ) + + code = models.IntegerField(_('code'), blank=False) # unique random number + amount = models.IntegerField(_('amount'), blank=False) + delay = models.IntegerField(default=0, blank=True) # number of days for delayed payments + payer = models.ForeignKey(User, on_delete=models.PROTECT, related_name='payments', blank=True, null=True) + payee = models.ForeignKey(User, on_delete=models.PROTECT, related_name='receipts') + state = models.IntegerField(choices=STATE_CHOICES, default=STATE_CHOICES.created) + create = models.DateTimeField(_('create'), blank=True, null=True, auto_now=True) + last_update = models.DateTimeField(_('last update'), blank=True, null=True, auto_now=True) + type = None # invoice, charge, etc. it may be redundant + detail_id = models.IntegerField(null=True, blank=True, unique=True) # or better detail url or res number + + objects = TransactionManager() + + def set_code(self): + # TODO: set infinite algorithm + for code in random.sample(range(10000, 100000), 90000): + try: + code = str(code) + self.code = code + return self.save() + except: + pass + + raise APIException('system error') + + def pay(self, payer=None): + if not self.payer and not payer: + raise APIException('payer identification error') + + if self.payer and payer and not self.payer == payer: + raise APIException('payer identification error') + + if not self.payer: + self.payer = payer + + with transaction.atomic(): + # TODO: check bundry conditions + self.last_update = datetime.datetime.now() + + if not self.state == self.STATE_CHOICES.created: + raise APIException('this transaction is not ready to pay') + + if self.payer.balance < self.amount: + raise APIException('not enough balance') + + self.payer.balance = F('balance') - self.amount + self.payer.save(update_fields=['balance']) + + self.payer.refresh_from_db() + if self.payer.balance < 0: + raise APIException('not enough balance') + + if self.delay: + self.state = self.STATE_CHOICES.delayed + self.save() + + else: + self.payee.balance = F('balance') + self.amount + self.payee.save(update_fields=['balance']) + + self.state = self.STATE_CHOICES.done + self.save() + + self.save() + return self + + def receipt(self): + with transaction.atomic(): + self.last_update = datetime.datetime.now() + # TODO: check bundry conditions + if not self.state == self.STATE_CHOICES.delayed: + raise APIException('this transaction is not delayed') + + self.payee.balance = F('balance') + self.amount + self.state = self.STATE_CHOICES.done + self.payee.save() + self.save() + + def __str__(self): + return f"{self.payer} -> {self.payee} ({self.code}): {self.amount}" diff --git a/apps/transactions/serializers.py b/apps/transactions/serializers.py new file mode 100755 index 0000000..d7d7d07 --- /dev/null +++ b/apps/transactions/serializers.py @@ -0,0 +1,41 @@ +from django.db.transaction import atomic +from rest_framework import serializers + +from apps.users.serializers import PublicUserSerializer +from .models import Transaction + + +class TransactionSerializer(serializers.ModelSerializer): + payer = PublicUserSerializer(required=False) + payee = PublicUserSerializer(required=False, read_only=True) + + class Meta: + model = Transaction + fields = ('payer', + 'payee', + "create", + "state", + "code", + 'amount', + 'delay', + 'last_update', + 'detail_id' + ) + + read_only_fields = ['last_update', 'code', 'state', 'create', 'detail_id'] + + def create(self, validated_data): + + payer = validated_data.get('payer') + request = self.context.get('request') + user = request.user if request else payer + + if user == payer: + with atomic(): + transaction = Transaction.objects.create_transaction(**validated_data) + transaction.pay() + + else: + transaction = Transaction.objects.create_transaction(**validated_data) + + return transaction diff --git a/apps/transactions/tests.py b/apps/transactions/tests.py new file mode 100755 index 0000000..7ce503c --- /dev/null +++ b/apps/transactions/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/transactions/views.py b/apps/transactions/views.py new file mode 100755 index 0000000..103469b --- /dev/null +++ b/apps/transactions/views.py @@ -0,0 +1,67 @@ +from django.db.models import Q + +from oauth2_provider.contrib.rest_framework import TokenHasScope, IsAuthenticatedOrTokenHasScope +from rest_framework import generics, permissions +from rest_framework.response import Response + +from apps.transactions.models import Transaction +from apps.transactions.serializers import TransactionSerializer + + +class TransactionList(generics.ListCreateAPIView): + permission_classes = [permissions.IsAuthenticated, IsAuthenticatedOrTokenHasScope] + serializer_class = TransactionSerializer + required_scopes = [] + + def get_queryset(self): + user = self.request.user + return Transaction.objects.filter(Q(payee_id=user.id) | Q(payer_id=user.id)).all() + + def perform_create(self, serializer): + user = self.request.user + serializer.save(payee=user) + + +class TransactionDetail(generics.RetrieveAPIView): + permission_classes = [permissions.IsAuthenticated, IsAuthenticatedOrTokenHasScope] + serializer_class = TransactionSerializer + lookup_field = 'code' + required_scopes = [] + + def get_queryset(self): + user = self.request.user + return Transaction.objects.filter(Q(payee_id=user.id) | Q(payer_id=user.id) | Q(payer__isnull=True)).all() + + +class TransactionPay(generics.RetrieveAPIView): + permission_classes = [permissions.IsAuthenticated, IsAuthenticatedOrTokenHasScope] + serializer_class = TransactionSerializer + lookup_field = 'code' + required_scopes = [] + + def get_queryset(self): + user = self.request.user + return Transaction.objects.filter(Q(payer_id=user.id) | Q(payer__isnull=True)).all() + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + instance.pay(payer=self.request.user) + serializer = self.get_serializer(instance) + return Response(serializer.data) + + +class TransactionReceipt(generics.RetrieveAPIView): + permission_classes = [permissions.IsAuthenticated, IsAuthenticatedOrTokenHasScope] + serializer_class = TransactionSerializer + lookup_field = 'code' + required_scopes = [] + + def get_queryset(self): + user = self.request.user + return Transaction.objects.filter(payer_id=user.id).all() + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + instance.receipt() + serializer = self.get_serializer(instance) + return Response(serializer.data) diff --git a/utils.py b/utils.py old mode 100644 new mode 100755 index abdb949..b296b4d --- a/utils.py +++ b/utils.py @@ -1,5 +1,3 @@ -import random -from time import time from oauth2_provider.oauth2_validators import OAuth2Validator from django.contrib.auth import get_user_model @@ -7,11 +5,6 @@ from django.contrib.auth import get_user_model USER_MODEL = get_user_model() -def random_code(pre_len=5, post_len=5): - code = str(time())[:pre_len] + ''.join(random.choice('0123456789') for _ in range(post_len)) - return code - - class MultiGatewayOAuth2Validator(OAuth2Validator): # pylint: disable=w0223 """ Primarily extend the functionality of token generation """