diff --git a/apps/core/migrations/0002_alter_charge_state.py b/apps/core/migrations/0002_alter_charge_state.py new file mode 100644 index 0000000..e047eea --- /dev/null +++ b/apps/core/migrations/0002_alter_charge_state.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-02-07 05:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='charge', + name='state', + field=models.IntegerField(choices=[(1, 'created'), (2, 'delayed'), (3, 'pending'), (4, 'incomplete'), (5, 'success'), (6, 'failed'), (7, 'expected_failure'), (8, 'ROLLED_BACK')], default=1), + ), + ] diff --git a/apps/wallet/migrations/0017_transaction_reference_alter_transaction_state_and_more.py b/apps/wallet/migrations/0017_transaction_reference_alter_transaction_state_and_more.py new file mode 100644 index 0000000..5d3abd5 --- /dev/null +++ b/apps/wallet/migrations/0017_transaction_reference_alter_transaction_state_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0 on 2026-02-07 05:57 + +import django.contrib.postgres.indexes +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wallet', '0016_rename_category_wallet_and_more'), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='transaction', + name='reference', + field=models.UUIDField(db_index=True, null=True), + ), + migrations.AlterField( + model_name='transaction', + name='state', + field=models.IntegerField(choices=[(1, 'created'), (2, 'delayed'), (3, 'pending'), (4, 'incomplete'), (5, 'success'), (6, 'failed'), (7, 'expected_failure'), (8, 'ROLLED_BACK')], default=1), + ), + migrations.AddIndex( + model_name='transaction', + index=django.contrib.postgres.indexes.GinIndex(fields=['details'], name='wallet_transaction_details_a7ff79aa'), + ), + ] diff --git a/apps/wallet/models.py b/apps/wallet/models.py index fe20004..145fdab 100755 --- a/apps/wallet/models.py +++ b/apps/wallet/models.py @@ -1,6 +1,7 @@ import logging from functools import cached_property +from django.contrib.postgres.indexes import GinIndex from django.core.validators import MinValueValidator from django.db import models from django.db import transaction @@ -32,8 +33,8 @@ class AccountQuerySet(models.QuerySet): class Account(BaseModel): owner_uuid = models.UUIDField(db_index=True, null=False) - wallet = models.ForeignKey('Wallet', on_delete=models.PROTECT, null=True) owner_type = models.IntegerField(choices=TypeChoices.choices, db_index=True) + wallet = models.ForeignKey('Wallet', on_delete=models.PROTECT, null=True) balance = models.BigIntegerField(_('balance'), default=0) # TODO: unique together wallet and owner_uuid @@ -111,6 +112,8 @@ class Transaction(BaseModel): db_index=True) details = models.JSONField(null=True, blank=True, default=dict) + reference = models.UUIDField(db_index=True, null=True) + # detail = {'payer_name', 'payee_name', 'description', 'application_name', 'payer_avatar_url', 'payee_avatar_url', # 'reference_id', 'application_details_url'} @@ -118,6 +121,13 @@ class Transaction(BaseModel): class Meta: ordering = ['-created_at'] + indexes = [ + GinIndex( + fields=['details'], + # hashlib.md5("wallet_transaction_details_GinIndex".encode()).hexdigest()[:8] used for hash + name='wallet_transaction_details_a7ff79aa' + ), + ] def __str__(self): try: @@ -381,3 +391,29 @@ class Transaction(BaseModel): if is_updated: print('update details') self.save() + +# TODO: bulk +# class PaymentRequest(BaseModel): +# state = models.IntegerField(choices=StateChoices.choices, default=StateChoices.CREATED) +# application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, +# related_name='transactions', null=True) +# +# payer_uuid = models.UUIDField(db_index=True, null=False) +# payer_type = models.IntegerField(choices=TypeChoices.choices, db_index=True) +# +# payee_uuid = models.UUIDField(db_index=True, null=False) +# payee_type = models.IntegerField(choices=TypeChoices.choices, db_index=True) +# +# details = models.JSONField(null=True, blank=True, default=dict) +# +# def submit(self): +# with transaction.atomic(): +# pass +# +# def verify(self): +# with transaction.atomic(): +# pass +# +# def rollback(self): +# with transaction.atomic(): +# pass diff --git a/apps/wallet/views/user.py b/apps/wallet/views/user.py index 29eda33..1626fcf 100755 --- a/apps/wallet/views/user.py +++ b/apps/wallet/views/user.py @@ -7,10 +7,12 @@ from django.views.generic import DetailView, ListView, UpdateView from django_filters.rest_framework import DjangoFilterBackend from oauth2_provider.contrib.rest_framework import TokenHasScope, IsAuthenticatedOrTokenHasScope -from rest_framework import generics, permissions +from rest_framework import generics, permissions, mixins +from rest_framework.decorators import action from rest_framework.generics import get_object_or_404 from rest_framework.permissions import AllowAny from rest_framework.response import Response +from rest_framework.viewsets import ViewSet, GenericViewSet from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements from apps.wallet.models import Transaction, Wallet, Account @@ -57,7 +59,7 @@ class TransactionList(generics.ListAPIView): required_alternate_scopes = { "GET": [['wallet.user:transaction_list']], # "POST": [['wallet.invoice:create']], - } + } filter_backends = (DjangoFilterBackend,) filterset_class = UserTransactionFilter @@ -66,7 +68,6 @@ class TransactionList(generics.ListAPIView): user = self.request.user return Transaction.objects.filter(Q(payee_id=user.pk, payee_type=TypeChoices.USER.value) | Q(payer_id=user.pk, payer_type=TypeChoices.USER.value)).all() - # TODO: # def perform_create(self, serializer): # payer = self.request.user @@ -166,6 +167,7 @@ class WithdrawSubmit(generics.CreateAPIView): payee = self.request.auth.application.user serializer.save(payee=payee, application=get_application(self.request)) + # TODO: refactor class WithdrawVerify(generics.RetrieveAPIView): permission_classes = [TokenHasScope] @@ -226,7 +228,6 @@ class TransactionDetailView(UpdateView): return super().form_valid(form) - class WalletAccountListAPIView(generics.ListAPIView): ''' API for getting authenticated user balance. @@ -238,7 +239,7 @@ class WalletAccountListAPIView(generics.ListAPIView): required_alternate_scopes = { "GET": [['wallet.user:transaction_list']], # "POST": [['wallet.invoice:create']], - } + } queryset = Account.objects.all() def get_queryset(self): @@ -267,7 +268,7 @@ class TransactionListAPIView(generics.ListAPIView): required_alternate_scopes = { "GET": [['wallet.user:transaction_list']], # "POST": [['wallet.invoice:create']], - } + } filter_backends = (DjangoFilterBackend,) filterset_class = UserTransactionFilter @@ -293,7 +294,7 @@ class WalletTransactionListView(generics.ListAPIView): required_alternate_scopes = { "GET": [['wallet.user:transaction_list']], # "POST": [['wallet.invoice:create']], - } + } filter_backends = (DjangoFilterBackend,) filterset_class = UserTransactionFilter @@ -306,3 +307,68 @@ class WalletTransactionListView(generics.ListAPIView): return Transaction.objects.filter(Q(payee_account=account) | Q(payer_account=account)).all() +class UserTransactionViewSet(mixins.ListModelMixin, + # mixins.CreateModelMixin, + # mixins.RetrieveModelMixin, + # mixins.UpdateModelMixin, + # mixins.DestroyModelMixin, + GenericViewSet): + + permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] + serializer_class = TransactionSerializer + required_alternate_scopes = { + "GET": [['wallet.user:transaction_list']], + # "POST": [['wallet.invoice:create']], + } + + filter_backends = (DjangoFilterBackend,) + filterset_class = UserTransactionFilter + queryset = Transaction.objects.all() + + # TODO: redundant + def get_queryset(self): + user = self.request.user + return Transaction.objects.filter(Q(payee_account__owner_uuid=user.uuid) | + Q(payer_account__owner_uuid=user.uuid)).all() + + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + + @action(detail=False, methods=["GET"]) + def grouped(self): + from collections import defaultdict + from django.db.models import Count, Sum + + # فقط ۲ کوئری بهینه + # کوئری ۱: آمار کلی (سبک) + stats = dict(Transaction.objects.values('refrence').annotate( + total=Sum('amount'), + count=Count('id') + ).values_list('refrence', 'total', 'count')) + + # کوئری ۲: همه تراکنش‌ها + transactions = Transaction.objects.all().order_by('refrence', 'created_at') + + # گروه‌بندی در پایتون + groups = defaultdict(list) + for t in transactions: + groups[t.refrence].append(t) + + # ترکیب + result = [] + for ref, items in groups.items(): + result.append({ + 'refrence': ref, + 'stats': {'total': stats.get(ref, 0), 'count': len(items)}, + 'transactions': items + })