浏览代码

* Add transaction application
* Remove permissions

kaajavi 5 年之前
父节点
当前提交
4311f408cc

+ 63 - 0
Readme.md

@@ -0,0 +1,63 @@
+# Belvo challenge
+
+Author: Javier Guignard
+
+You can see demo in challenge.kaajavi.com
+
+## Setup
+
+The first thing to do is to clone the repository:
+
+```sh
+$ git clone https://github.com/gocardless/sample-django-app.git
+$ cd application
+```
+
+Create a virtual environment to install dependencies in and activate it:
+
+```sh
+$ virtualenv2 --no-site-packages env
+$ source env/bin/activate
+```
+
+Then install the dependencies:
+
+```sh
+(env)$ pip install -r requirements.txt
+```
+Note the `(env)` in front of the prompt. This indicates that this terminal
+session operates in a virtual environment set up by `virtualenv2`.
+
+Once `pip` has finished downloading the dependencies:
+```sh
+(env)$ python manage.py migrate
+(env)$ python manage.py create_superuser
+(env)$ python manage.py runserver
+```
+And navigate to `http://127.0.0.1:8000/`.
+
+The home page is the API documentation.
+
+## What do you need know about the challenge
+
+I didn't set up the authentication by JWT or similar, because of the short time.
+
+
+## To run in docker-compose
+
+I suppose you have installed docker and docker-compose. If not, try with [here](https://docs.docker.com/compose/install/)
+
+Run:
+```sh
+$ docker-compose up
+``` 
+and server runs in development mode.  
+After run, the command call db migrates, create superuser if not exists and run server in **8080 port**.
+
+## Tests
+
+To run the tests, `cd` into the directory where `manage.py` is:
+```sh
+(env)$ python manage.py test users
+(env)$ python manage.py test transactions
+```

+ 2 - 1
application/settings.py

@@ -25,7 +25,7 @@ SECRET_KEY = 'd8wx7t%$08@744ad-u(5az(^5l_l5(o3j1&+6_^ndo6mhjttnv'
 # SECURITY WARNING: don't run with debug turned on in production!
 DEBUG = True
 
-ALLOWED_HOSTS = []
+ALLOWED_HOSTS = ['*']
 
 
 # Application definition
@@ -41,6 +41,7 @@ DJ_APPS = [
 #3th part applications
 TP_APPS = [
     'rest_framework',
+    'django_extensions',
 ]
 #Work applications
 WT_APPS = [

+ 6 - 3
application/urls.py

@@ -17,15 +17,18 @@ from django.contrib import admin
 from django.urls import include, path
 from django.utils.html import format_html
 from users.urls import router as user_router
+from transactions.urls import router as transaction_router
 from rest_framework.documentation import include_docs_urls
 
-title = format_html('''API Documentation <span style="font-size:10px">
-By <a style="font-size:10px" target="_blank" href="https://www.linkedin.com/in/javierguignard">Javier Guignard</a></style>''')
+title = format_html('''API Documentation <span style="font-size:11px">
+By <a style="font-size:10px" target="_blank" href="https://www.linkedin.com/in/javierguignard">Javier Guignard 
+<i class="fa fa-linkedin-square" aria-hidden="true"></i> </a></style>''')
 # Wire up our API using automatic URL routing.
 # Additionally, we include login URLs for the browsable API.
 urlpatterns = [
     path('', include_docs_urls(title=title)),
-    path('api/users/', include(user_router.urls)),
+    path('api/users/', include((user_router.urls, 'users'),namespace='users_path')),
+    path('api/transactions/', include((transaction_router.urls, 'transactions'), namespace='transactions')),
     path('admin/', admin.site.urls),
     path('api-auth/', include('rest_framework.urls', namespace='rest_framework'))
 ]

+ 4 - 1
requirements.txt

@@ -4,12 +4,15 @@ chardet==3.0.4
 coreapi==2.3.3
 coreschema==0.0.4
 Django==3.1.3
+django-extensions==3.0.9
 djangorestframework==3.12.2
 idna==2.10
 itypes==1.2.0
 Jinja2==2.11.2
+Markdown==3.3.3
 MarkupSafe==1.1.1
-pkg-resources==0.0.0
+psycopg2-binary==2.8.6
+Pygments==2.7.2
 pytz==2020.4
 requests==2.24.0
 sqlparse==0.4.1

+ 0 - 0
transactions/__init__.py


+ 4 - 0
transactions/admin.py

@@ -0,0 +1,4 @@
+from django.contrib import admin
+from .models import Transaction
+# Register your models here.
+admin.site.register(Transaction)

+ 7 - 0
transactions/apps.py

@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class TransactionsConfig(AppConfig):
+    name = 'transactions'
+    verbose_name = "Transaction"
+    verbose_name_plural= "Transactions"

+ 30 - 0
transactions/migrations/0001_initial.py

@@ -0,0 +1,30 @@
+# Generated by Django 3.1.3 on 2020-11-11 22:30
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import transactions.models
+
+
+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')),
+                ('reference', models.CharField(max_length=8, unique=True, verbose_name='Transaction reference')),
+                ('account', models.CharField(max_length=8, verbose_name='Account')),
+                ('type', models.CharField(choices=[('inflow', 'inflow'), ('outflow', 'outflow')], max_length=8, validators=[transactions.models.validate_type], verbose_name='Type')),
+                ('amount', models.FloatField(max_length=255, verbose_name='Amount')),
+                ('category', models.CharField(max_length=255, verbose_name='Account')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]

+ 35 - 0
transactions/migrations/0002_auto_20201112_1317.py

@@ -0,0 +1,35 @@
+# Generated by Django 3.1.3 on 2020-11-12 13:17
+
+import datetime
+from django.db import migrations, models
+import transactions.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('transactions', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='transaction',
+            name='date',
+            field=models.DateField(default=datetime.date(2020, 11, 12), verbose_name='Date'),
+        ),
+        migrations.AlterField(
+            model_name='transaction',
+            name='amount',
+            field=models.FloatField(help_text='Only negative numbers for "outflow" and\n                                           positive numbers for "inflow"', max_length=255, verbose_name='Amount'),
+        ),
+        migrations.AlterField(
+            model_name='transaction',
+            name='reference',
+            field=models.CharField(help_text='Unique value', max_length=8, unique=True, verbose_name='Transaction reference'),
+        ),
+        migrations.AlterField(
+            model_name='transaction',
+            name='type',
+            field=models.CharField(choices=[('inflow', 'inflow'), ('outflow', 'outflow')], help_text='Choice "outflow" or "inflow"', max_length=8, validators=[transactions.models.validate_type], verbose_name='Type'),
+        ),
+    ]

+ 0 - 0
transactions/migrations/__init__.py


+ 15 - 0
transactions/mixins.py

@@ -0,0 +1,15 @@
+class TransactionsMixin:
+    """
+    TransactionMixin are essentially just a type of class mixin
+    when define common functions for all Viewsets
+    """
+
+    def get_filtered_transactions(self):
+        date_from = self.request.query_params.get('date_from', None)
+        date_to = self.request.query_params.get('date_from', None)
+        transactions = self.user.transactions
+        if date_from:
+            transactions = transactions.filter(date__gte=date_from)
+        if date_to:
+            transactions = transactions.filter(date__lte=date_to)
+        return transactions

+ 31 - 0
transactions/models.py

@@ -0,0 +1,31 @@
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+from django.contrib.auth import get_user_model
+from django.utils.timezone import now
+User = get_user_model()
+
+
+# Create your models here.
+def validate_type(value):
+    if value not in ('inflow', 'outflow'):
+        raise ValidationError(
+            _('%(value)s is not an even number'),
+            params={'value': value},
+        )
+
+
+class Transaction(models.Model):
+    user = models.ForeignKey(User,related_name='transactions', on_delete=models.CASCADE)
+    reference = models.CharField(_('Transaction reference'), max_length=8, unique=True,
+                                 help_text=_('Unique value'))
+    account = models.CharField(_('Account'), max_length=8, help_text=_('Account Number'))
+    type = models.CharField(_('Type'), max_length=8,
+                            choices=(('inflow', 'inflow'), ('outflow', 'outflow')),
+                            validators=[validate_type],
+                            help_text=_('Choice "outflow" or "inflow"'))
+    amount = models.FloatField(_('Amount'), max_length=255,
+                               help_text=_('''Only negative numbers for "outflow" and
+                                           positive numbers for "inflow"'''))
+    category = models.CharField(_('Category'), max_length=255, help_text=_('Category Name'))
+    date = models.DateField('Date',help_text=_('Transaction date. YYYY-MM-DD format.'))

+ 45 - 0
transactions/serializers.py

@@ -0,0 +1,45 @@
+from rest_framework import serializers
+from .models import Transaction
+from django.utils.translation import ugettext_lazy as _
+from django.contrib.auth import get_user_model
+
+User = get_user_model()
+
+
+class CreateTransactionSerializer(serializers.ModelSerializer):
+    user_id = serializers.PrimaryKeyRelatedField(
+        queryset=User.objects.all(), source='user', write_only=True, help_text='User Id')
+
+    def __init__(self, *args, **kwargs):
+        many = kwargs.pop('many', True)
+        super(CreateTransactionSerializer, self).__init__(many=many, *args, **kwargs)
+
+    def validate(self, data):
+        """
+        Check the type is outflow or inflow
+        Check the inflow is positive and outflow is negative
+        """
+        if data['type'] not in ('outflow','inflow'):
+            raise serializers.ValidationError({"type": _("Must be `outflow` or `inflow` ")})
+        if data['amount'] > 0 and data['type'] == 'outflow':
+            raise serializers.ValidationError({"amount":_("Outflow type only allow negatives amount")})
+        if data['amount'] < 0 and data['type'] == 'inflow':
+            raise serializers.ValidationError({"amount":_("Inflow type only allow positives amount")})
+        return data
+
+    class Meta:
+        model = Transaction
+        fields = ('user_id', 'reference', 'account', 'type', 'amount', 'category','date')
+
+
+class BalanceByAccountSerializer(serializers.ModelSerializer):
+
+    balance = serializers.FloatField(label='balance', read_only=True)
+    total_inflow = serializers.FloatField(label='balance', read_only=True)
+    total_outflow = serializers.FloatField(label='balance', read_only=True)
+
+    class Meta:
+        model = Transaction
+        fields = ['account', 'balance','total_inflow','total_outflow']
+
+

+ 11 - 0
transactions/urls.py

@@ -0,0 +1,11 @@
+from django.conf.urls import url, include
+from rest_framework import routers
+
+from .views import TransactionViewSet,AccountBalanceViewSet,CashflowViewSet
+
+app_name = 'transactions'
+router = routers.DefaultRouter()
+router.register(r'', TransactionViewSet, basename='transaction')
+router.register(r'balance', AccountBalanceViewSet, basename='balance')
+router.register(r'cashflow', CashflowViewSet, basename='cashflow')
+

+ 124 - 0
transactions/views.py

@@ -0,0 +1,124 @@
+from django.http import Http404
+from rest_framework import viewsets, mixins
+from rest_framework import permissions
+from rest_framework.response import Response
+from .mixins import TransactionsMixin
+from django.db.models import (Sum, F, Case, FloatField,
+                              When)
+from .serializers import (CreateTransactionSerializer,
+                          BalanceByAccountSerializer)
+from .models import Transaction
+from django.contrib.auth import get_user_model
+
+User = get_user_model()
+
+
+class TransactionViewSet(mixins.CreateModelMixin,
+                         viewsets.GenericViewSet):
+    """
+    API endpoint to create transactions
+    Can send one or more transactions by request.
+    Query example:
+
+    ```json
+        [
+          {
+            "reference": "000053",
+            "account": "C00099",
+            "date": "2020-01-03",
+            "amount": -51.13,
+            "type": "outflow",
+            "category": "groceries",
+            "user_id": 1
+          },
+          {
+            "reference": "000054",
+            "account": "C00099",
+            "date": "2020-01-10",
+            "amount": 2500.60,
+            "type": "inflow",
+            "category": "groceries",
+            "user_id": 1
+          }
+        ]
+    ```
+
+    """
+    queryset = Transaction.objects.all()
+    serializer_class = CreateTransactionSerializer
+    permission_classes = []
+
+    def create(self, request, *args, **kwargs):
+        serializer = self.get_serializer(data=request.data, many=isinstance(request.data, list))
+        serializer.is_valid(raise_exception=True)
+        self.perform_create(serializer)
+        return Response(serializer.data)
+
+
+class AccountBalanceViewSet(viewsets.ViewSet, TransactionsMixin):
+    """
+    ### Retrieve summary by accounts of user.
+
+    Optionally restricts the returned account balance to a given user,
+    by filtering against a `date_from` and `date_to` query parameter in the URL.
+
+    Date Format: `YYYY-MM-DD`
+
+    Query example:
+
+        /api/transactions/balance/account/1/?date_from=2020-01-10
+
+    """
+    permission_classes = []
+    lookup_field = 'user_id'
+
+    def retrieve(self, request, format=None, user_id=None, *args, **kwargs):
+        try:
+            self.user = User.objects.get(pk=user_id)
+        except User.DoesNotExist:
+            raise Http404
+        transactions = self.get_filtered_transactions()
+        with_annotations = transactions.values('account').annotate(balance=Sum('amount'),
+                                                                   total_inflow=Sum(Case(
+                                                                       When(type='inflow', then=F('amount')),
+                                                                       output_field=FloatField(),
+                                                                   )), total_outflow=Sum(Case(
+                When(type='outflow', then=F('amount')),
+                output_field=FloatField(),
+            )), )
+        serializer = BalanceByAccountSerializer(with_annotations, many=True)
+        return Response(serializer.data)
+
+
+class CashflowViewSet(viewsets.ViewSet, TransactionsMixin):
+    """
+    ### Retrieve summary by category
+
+    Optionally restricts the returned cashflow to a given user,
+    by filtering against a `date_from` and `date_to` query parameter in the URL.
+
+    Date Format: `YYYY-MM-DD`
+
+    Query example:
+
+        /api/transactions/cashflow/1/?date_from=2020-01-10
+
+    """
+    permission_classes = []
+    lookup_field = 'user_id'
+
+    def retrieve(self, request, format=None, user_id=None, *args, **kwargs):
+        try:
+            self.user = User.objects.get(pk=user_id)
+        except User.DoesNotExist:
+            raise Http404
+        transactions = self.get_filtered_transactions()
+        inflows_qs = list(
+            transactions.filter(type='inflow').values('category').annotate(amount=Sum('amount')).values('category',
+                                                                                                        'amount'))
+        outflow_qs = list(
+            transactions.filter(type='outflow').values('category').annotate(amount=Sum('amount')).values('category',
+                                                                                                         'amount'))
+        inflow = {inf['category']: inf['amount'] for inf in inflows_qs}
+        outflow = {outf['category']: outf['amount'] for outf in outflow_qs}
+        return Response({'inflow': inflow, 'outflow': outflow})

+ 2 - 0
users/apps.py

@@ -3,3 +3,5 @@ from django.apps import AppConfig
 
 class UsersConfig(AppConfig):
     name = 'users'
+    verbose_name = "User"
+    verbose_name_plural = "Users"

+ 1 - 1
users/models.py

@@ -1,5 +1,5 @@
 from django.db import models
-from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, AbstractUser
+from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
 from django.utils.translation import ugettext_lazy as _
 from .managers import UserManager
 

+ 2 - 1
users/urls.py

@@ -1,5 +1,6 @@
 from .views import UserViewSet
 from rest_framework import routers
 
+app_name = 'users'
 router = routers.DefaultRouter()
-router.register(r'', UserViewSet)
+router.register(r'', UserViewSet, basename='users')

+ 1 - 1
users/views.py

@@ -10,4 +10,4 @@ class UserViewSet(viewsets.ModelViewSet):
     """
     queryset = User.objects.all()
     serializer_class = UserSerializer
-    permission_classes = [permissions.IsAuthenticated]
+    permission_classes = []