Compare commits

...

42 Commits

Author SHA1 Message Date
DJ Gillespie
dd3eee2a76 add traceback for plaid errors 2024-11-20 16:36:46 -07:00
DJ Gillespie
88f70323b0 fix plaid serializer to work with hyperlinked id fields 2024-10-12 18:52:23 +00:00
DJ Gillespie
4e188cd57e add banks to connection viewset. Change plaid auth to return connection serializer 2024-10-12 12:24:29 -06:00
DJ Gillespie
20aa85bf08 move avail accounts mixin to account mixins file. Add avail accounts mixin to Connection viewsets 2024-07-31 19:21:08 -06:00
DJ Gillespie
1411fbdf53 move avail accounts mixin to account mixins file. Add avail accounts mixin to Connection viewsets 2024-07-31 19:20:58 -06:00
DJ Gillespie
0d150971f7 change accessible_accounts to not use unions to allow for all queryset operations to be performed. 2024-07-31 19:12:53 -06:00
DJ Gillespie
8d15f325bb enable filtering per user account to only show owned objects. 2024-07-31 18:55:31 -06:00
DJ Gillespie
b98ad76080 upgrade django-allauth version to fix setuptools issue 2024-07-17 19:18:21 -06:00
DJ Gillespie
05bdc13fc4 add wheel unpinned install to workaround django-allauth setuptools bug as documented here: https://github.com/actions/setup-python/issues/872 2024-07-17 19:15:47 -06:00
DJ Gillespie
9c9a397d83 update plaid authentication to match format of v1 success response. 2024-07-17 18:54:07 -06:00
David Gillespie
5d35596c12 add refresh token re-issue on refresh to keep thing rolling for users that are active 2024-05-08 21:29:43 -06:00
David Gillespie
8eb3190972 enable auth guard on all private endpoints; Add login button to browsable API 2024-05-08 21:13:17 -06:00
David Gillespie
eafa2bbe75 update dj-rest-auth; Add token expirations to login response; Set refresh expiration at 30 days, and access token at 1 day; 2024-05-08 21:13:10 -06:00
DJ Gillespie
93aa10d98d fix is_anonymous call error 2024-03-05 14:33:27 -07:00
DJ Gillespie
0c98606234 add manual account override for when unauthenticated users call the auth endpoint. 2024-03-05 14:27:20 -07:00
DJ Gillespie
6ec9065f8e add manual account override for when unauthenticated users call the auth endpoint. 2024-03-05 14:20:54 -07:00
DJ Gillespie
8bdbc05c59 Merge branch 'plaid-upgrade' into dev-master 2024-03-03 00:10:07 -07:00
DJ Gillespie
cca13d4a4f finalize plaid v2 migration 2024-03-03 00:05:20 -07:00
DJ Gillespie
64e8ae4bab create subscription plan model and add qol names to api. 2024-01-17 20:40:35 -07:00
DJ Gillespie
8dc43c0132 temporarily disable auth for connections 2024-01-17 19:59:34 -07:00
DJ Gillespie
dc1a60910f removed errant 'budget' reference in the serializer as that got renamed to 'balance' 2023-12-06 22:13:56 -07:00
DJ Gillespie
0640571fba added description and budget to Slice Transaction serializer 2023-12-06 22:07:58 -07:00
DJ Gillespie
b711675a0c added description and budget to Slice serializer 2023-12-06 22:07:22 -07:00
DJ Gillespie
23764c1886 finished up new link flow updates. Still need to implement secondary get_auth_token flow. 2023-12-06 21:05:00 -07:00
DJ Gillespie
42704350a4 added plaid_v2 client, started progress to migrate to LinkTokens from AuthTokens 2023-12-06 21:04:52 -07:00
DJ Gillespie
e942f1a76e finished up new link flow updates. Still need to implement secondary get_auth_token flow. 2023-12-06 21:04:07 -07:00
DJ Gillespie
cd9a8c4d29 fixed bank account filtering, and id filtering 2023-11-03 15:53:37 -06:00
DJ Gillespie
5b6c7bde1d added plaid_v2 client, started progress to migrate to LinkTokens from AuthTokens 2023-09-27 21:54:33 -06:00
DJ Gillespie
6b377067e1 added ssl header proxy setting to pass through https vs http properly to drf 2023-08-30 22:32:35 -06:00
DJ Gillespie
eee001e32b optional balance migration 2023-08-30 21:55:55 -06:00
DJ Gillespie
70453bcdcf made slice balance nullable 2023-08-30 21:45:33 -06:00
DJ Gillespie
0b32c1d20d slice fk migrations 2023-08-30 20:45:43 -06:00
DJ Gillespie
e3b1060624 added bank account fk to slice 2023-08-30 20:43:41 -06:00
DJ Gillespie
5e33e6a308 Merge branch 'master' into dev-master 2023-08-30 20:29:24 -06:00
DJ Gillespie
88b0f6242b cors attempt 3 2023-07-19 22:34:26 -06:00
DJ Gillespie
25bc7dc347 attempt 2 at cors 2023-07-19 22:26:09 -06:00
DJ Gillespie
899ea476d6 updated cors rules to allow for chrome webapps 2023-07-19 22:22:43 -06:00
DJ Gillespie
6d13ec9655 swapped primarykeyrelatedfield for transactionserializer 2023-07-19 19:29:00 -06:00
DJ Gillespie
c6b8f16a99 swapped primarykeyrelatedfield for transactionserializer 2023-07-19 19:27:16 -06:00
DJ Gillespie
8ff8b000e3 fixed errant budget reference. 2023-07-19 19:21:55 -06:00
DJ Gillespie
64a51b362a added missing migration for bankaccount 2023-07-19 19:15:30 -06:00
DJ Gillespie
b6e8c0f108 added slice name and id filtering to transactions, added transaction grouping by slice, changed bank name, and changed slice budget name. 2023-07-19 19:08:31 -06:00
21 changed files with 587 additions and 80 deletions

View File

@ -1,7 +1,7 @@
from django.contrib.auth.models import Group
from django.contrib.auth import get_user_model
from rest_framework import serializers
from qrtr_account.models import Account, Bank, Institution, Transaction, Slice, Rule
from qrtr_account.models import Account, BankAccount, Institution, Transaction, Slice, Rule, SubscriptionPlan
from user.models import User
from connection.models import Connection, ConnectionType
from connection.serializers import ConnectionTypeSerializer, ConnectionSerializer
@ -42,13 +42,20 @@ class UserSerializer(serializers.HyperlinkedModelSerializer):
class GroupSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Group
fields = ['url', 'name']
fields = ['pk', 'url', 'name']
class BankSerializer(serializers.HyperlinkedModelSerializer):
class SubscriptionPlanSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Bank
model = SubscriptionPlan
fields = ['pk', 'name', 'status']
class BankAccountSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BankAccount
fields = [
'pk',
'url',
'qrtr_account',
'connection',
@ -57,6 +64,10 @@ class BankSerializer(serializers.HyperlinkedModelSerializer):
'balance',
'ac_type',
'ac_subtype',
'connection_type',
'institution_name',
'plan',
'plan_name'
]
extra_kwargs = {
'balance': {'read_only': True},
@ -67,10 +78,10 @@ class BankSerializer(serializers.HyperlinkedModelSerializer):
}
class BankSerializerPOST(BankSerializer):
class BankAccountSerializerPOST(BankAccountSerializer):
"""Separate Serializer for POST requests to create a new bank. This adds
a new field called connection_details that is used to create a new
connection record to go with the new Bank. This field is only allowed on
connection record to go with the new BankAccount. This field is only allowed on
POST because we don't want to expose this information to the user, or allow
them to change it b/c that could lead to an integrity problem, breaking
their bank functionality.
@ -83,8 +94,9 @@ class BankSerializerPOST(BankSerializer):
# "credentials": {}})
class Meta:
model = Bank
model = BankAccount
fields = [
'pk',
'url',
'qrtr_account',
'connection',
@ -107,13 +119,13 @@ class BankSerializerPOST(BankSerializer):
class InstitutionSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Institution
fields = ['url', 'name']
fields = ['pk', 'url', 'name']
class TransactionSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Transaction
fields = ['url', 'authorized_date',
fields = ['pk', 'url', 'authorized_date',
'bank', 'name','details','slice','trans_id',
'updated_at','created_at']
@ -121,9 +133,15 @@ class TransactionSerializer(serializers.HyperlinkedModelSerializer):
class SliceSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Slice
fields = ['url', 'name', 'icon', 'budget', 'slice_of']
fields = ['pk', 'url', 'name', 'icon', 'description', 'balance', 'slice_of', 'bank_acc']
class SliceTransactionSerializer(serializers.ModelSerializer):
transactions = TransactionSerializer(many=True, read_only=True)
class Meta:
model = Slice
fields = ['pk', 'url', 'name', 'icon', 'description', 'balance', 'slice_of', 'transactions', 'bank_acc']
class RuleSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Rule

View File

@ -0,0 +1,150 @@
from .abstract import AbstractConnectionClient
from django.conf import settings
import os
import datetime
import plaid
from plaid.api import plaid_api
from plaid.model.link_token_create_request import LinkTokenCreateRequest
from plaid.model.link_token_create_request_user import LinkTokenCreateRequestUser
from plaid.model.item_public_token_exchange_request import ItemPublicTokenExchangeRequest
from plaid.model.transactions_sync_request import TransactionsSyncRequest
from plaid.model.accounts_get_request import AccountsGetRequest
from plaid.model.products import Products
from plaid.model.country_code import CountryCode
def format_error(e):
return {
'error': {
'display_message': e.display_message,
'error_code': e.code,
'error_type': e.type,
'error_message': e.message}}
class Connection(AbstractConnectionClient):
def __init__(self, credentials, account_id=None):
self.credentials = credentials
self.account_id = account_id
# Fill in your Plaid API keys -
# https://dashboard.plaid.com/account/keys
self.PLAID_CLIENT_ID = settings.PLAID_CLIENT_ID
self.PLAID_SECRET = settings.PLAID_SECRET
self.PLAID_PUBLIC_KEY = settings.PLAID_PUBLIC_KEY
# Use 'sandbox' to test with Plaid's Sandbox environment (usplaid-python==9.2.0ername: user_good,
# password: pass_good)
# Use `development` to test with live users and credentials and `production`
# to go live
self.PLAID_ENV = settings.PLAID_ENV
# PLAID_PRODUCTS is a comma-separated list of products to use when initializing
# Link. Note that this list must contain 'assets' in order for the app to be
# able to create and retrieve asset reports.
self.PLAID_PRODUCTS = settings.PLAID_PRODUCTS
# PLAID_COUNTRY_CODES is a comma-separated list of countries for which users
# will be able to select institutions from.
self.PLAID_COUNTRY_CODES = settings.PLAID_COUNTRY_CODES
configuration = plaid.Configuration(
host=self.PLAID_ENV,
api_key={
'clientId': self.PLAID_CLIENT_ID,
'secret': self.PLAID_SECRET,
}
)
self.api_client = plaid.ApiClient(configuration)
self.client = plaid_api.PlaidApi(self.api_client)
# # Create a link_token for the given user
# request = LinkTokenCreateRequest(
# products=[Products("auth")],
# client_name="Qrtr Plaid",
# country_codes=[CountryCode('US')],
# #redirect_uri='https://domainname.com/oauth-page.html',
# language='en',
# webhook='https://webhook.example.com',
# user=LinkTokenCreateRequestUser(
# client_user_id=self.account_id
# )
# )
# response = client.link_token_create(request)
# resp_dict = response.to_dict()
# resp_dict['expiration'] = resp_dict['expiration'].strftime('%s')
# self.credentials.update(resp_dict)
# return self.credentials
def generate_auth_request(self):
# Create a link_token for the given user
request = LinkTokenCreateRequest(
products=[Products("auth")],
client_name="Qrtr Plaid",
country_codes=[CountryCode('US')],
# redirect_uri='https://domainname.com/oauth-page.html',
language='en',
webhook='https://webhook.example.com',
user=LinkTokenCreateRequestUser(
client_user_id=self.account_id
)
)
response = self.client.link_token_create(request)
resp_dict = response.to_dict()
resp_dict['expiration'] = resp_dict['expiration'].strftime('%s')
self.credentials.update(resp_dict)
def get_auth_token(self, public_token):
try:
exchange_request = ItemPublicTokenExchangeRequest(
public_token=public_token
)
exchange_response = self.client.item_public_token_exchange(
exchange_request)
except Exception as e:
print("Error Occurred")
print(e)
return format_error(e)
access_token = exchange_response['access_token']
item_id = exchange_response['item_id']
self.credentials.update({"access_token": access_token, "item_id": item_id})
return {"access_token": access_token, "item_id": item_id}
def get_accounts(self, auth_token=None):
if not auth_token:
auth_token = self.credentials.get('access_token')
if not auth_token:
raise Exception("Missing Auth Token")
try:
acc_request = AccountsGetRequest(access_token=auth_token)
accounts = self.client.accounts_get(acc_request).to_dict()
except Exception as e:
print(e)
accounts = None
return accounts
def get_transactions(
self,
start_date=None,
end_date=None,
auth_token=None):
if not auth_token:
auth_token = self.credentials.get('access_token')
request = TransactionsSyncRequest(
access_token=auth_token,
)
response = self.client.transactions_sync(request)
transactions = response['added']
# the transactions in the response are paginated, so make multiple calls while incrementing the cursor to
# retrieve all transactions
while (response['has_more']):
request = TransactionsSyncRequest(
access_token=auth_token,
cursor=response['next_cursor']
)
response = self.client.transactions_sync(request)
transactions += response['added']
return transactions

View File

@ -1,5 +1,6 @@
from rest_framework import serializers
from .models import ConnectionType, Connection
from qrtr_account.models import BankAccount
class ConnectionTypeSerializer(serializers.HyperlinkedModelSerializer):
@ -11,12 +12,24 @@ class ConnectionTypeSerializer(serializers.HyperlinkedModelSerializer):
'filename': {'read_only': True}
}
class ConnectionSerializer(serializers.HyperlinkedModelSerializer):
class ConnectionAccountSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Connection
fields = ['url', 'name', 'type', 'credentials']
fields = ['pk', 'url', 'name', 'type']
class BankAccountReadSerializer(serializers.HyperlinkedModelSerializer):
connection = ConnectionAccountSerializer(read_only=True)
class Meta:
model = BankAccount
fields = ['pk', 'url', 'connection']
class ConnectionSerializer(serializers.HyperlinkedModelSerializer):
bank_accounts = BankAccountReadSerializer(read_only=True, source='*')
class Meta:
model = Connection
fields = ['url', 'name', 'type', 'credentials', 'bank_accounts']
extra_kwargs = {
'type': {'write_only': True},
'credentials': {'write_only': True}
}

View File

@ -1,4 +1,4 @@
from qrtr_account.models import Transaction, Bank, Account
from qrtr_account.models import Transaction, BankAccount, Account
from connection.models import Connection
@ -15,7 +15,7 @@ def get_and_save_transactions(connection, start_date=None, end_date=None):
defaults={"authorized_date": trns.get("authorized_date"),
"trans_id": trns.get("transaction_id"),
"details": trns,
"bank": Bank.objects.get(acc_id=trns.get("account_id")),
"bank": BankAccount.objects.get(acc_id=trns.get("account_id")),
"name": trns.get("name")})
return True

View File

@ -11,6 +11,9 @@ import importlib
import json
from .serializers import ConnectionSerializer, ConnectionTypeSerializer
from django.db import transaction
from qrtr_account.mixins import OwnedAccountsMixin
import traceback
# Create your views here.
@ -20,7 +23,7 @@ class ConnectionTypeViewSet(viewsets.ModelViewSet):
serializer_class = ConnectionTypeSerializer
class ConnectionViewSet(viewsets.ModelViewSet):
class ConnectionViewSet(viewsets.ModelViewSet, OwnedAccountsMixin):
"""API endpoint that allows connections to be seen or created
"""
permission_classes = [IsAuthenticated]
@ -33,28 +36,24 @@ class ConnectionViewSet(viewsets.ModelViewSet):
'delete',
'options']
@action(detail=False, methods=['post'], url_path='plaid')
def authenticate(self, request):
print(request.data)
print(request.data.keys())
public_token = request.data.get("public_token")
def get_queryset(self):
return Connection.objects.filter(
account__in=self.accessible_accounts().values_list('id'))
@action(detail=False, methods=['post'], url_path='plaid/exchange_public_token')
def exchange_public_token(self, request):
print(f"REQUEST: {request.data}")
name = request.data.get("name", "dummyName")
account_id = request.data.get("account")
print(f"Account ID Detected: {account_id}")
if public_token is None:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data="ERROR: missing public_token")
if account_id is None:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data="ERROR: missing account_id")
public_token = request.data.get("public_token")
user = request.user
# Filter out any accounts with the right id, but the given user
# is not an owner or admin on that account.
accounts = (Account.objects.filter(pk=account_id, owner=user) |
Account.objects.filter(pk=account_id,
admin_users__in=[user]))
if request.user.is_anonymous:
accounts = (Account.objects.filter(pk=1))
else:
accounts = (Account.objects.filter(pk=account_id, owner=user) |
Account.objects.filter(pk=account_id,
admin_users__in=[user]))
if not accounts:
return Response(
status=status.HTTP_400_BAD_REQUEST,
@ -63,15 +62,73 @@ class ConnectionViewSet(viewsets.ModelViewSet):
print(f"Account Found: {accounts[0]}")
account = accounts[0]
print(request)
plaid_conn = importlib.import_module(f"connection.connections.plaid_client")
plaid_conn = importlib.import_module(f"connection.connections.plaid_client_v2")
conn_type = ConnectionType.objects.get(name="Plaid")
try:
plaid_client = plaid_conn.Connection(request.data)
plaid_client = plaid_conn.Connection(request.data.dict(), account_id=account_id)
token = plaid_client.get_auth_token(public_token)
except ValueError:
return Response(status=status.HTTP_503,
data="ERROR: Invalid public_token")
with transaction.atomic():
conn, created = Connection.objects \
.get_or_create(name=name, type=conn_type,
defaults={
"credentials": request.data,
"account": account
})
conn.credentials = plaid_client.credentials
print(f"CREDS: {plaid_client.credentials}")
conn.save()
serializer = self.get_serializer(conn, context={'request':request})
print("DATA:")
print(serializer.data)
return Response(serializer.data)
@action(detail=False, methods=['post'], url_path='plaid')
def authenticate(self, request):
print(request.data)
print(request.data.keys())
# public_token = request.data.get("public_token")
name = request.data.get("name", "dummyName")
account_id = request.data.get("account")
print(f"Account ID Detected: {account_id}")
# if public_token is None:
# return Response(
# status=status.HTTP_400_BAD_REQUEST,
# data="ERROR: missing public_token")
if account_id is None:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data="ERROR: missing account_id")
user = request.user
# Filter out any accounts with the right id, but the given user
# is not an owner or admin on that account.
if request.user.is_anonymous:
accounts = (Account.objects.filter(pk=1))
else:
accounts = (Account.objects.filter(pk=account_id, owner=user) |
Account.objects.filter(pk=account_id,
admin_users__in=[user]))
if not accounts:
return Response(
status=status.HTTP_400_BAD_REQUEST,
data="ERROR: Account ID not found")
else:
print(f"Account Found: {accounts[0]}")
account = accounts[0]
print(request)
plaid_conn = importlib.import_module(f"connection.connections.plaid_client_v2")
conn_type = ConnectionType.objects.get(name="Plaid")
try:
plaid_client = plaid_conn.Connection(request.data.dict(), account_id=account_id)
plaid_client.generate_auth_request()
except ValueError:
return Response(status=status.HTTP_503,
data="ERROR: Invalid public_token")
except Exception as e:
print(e)
print(traceback.format_exc())
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR,
data="ERROR: Unable to contact Plaid")
with transaction.atomic():
@ -82,8 +139,9 @@ class ConnectionViewSet(viewsets.ModelViewSet):
"account": account
})
conn.credentials = plaid_client.credentials
print(f"CREDS: {plaid_client.credentials}")
conn.save()
return Response(plaid_client.get_accounts())
return Response(plaid_client.credentials)
@action(detail=False, methods=['post'], url_path='plaid-webhook',
permission_classes=[AllowAny])

View File

@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/2.2/ref/settings/
import os
import smtplib
from datetime import timedelta
try:
from .local import *
@ -122,6 +123,20 @@ REST_FRAMEWORK = {
REST_USE_JWT = True
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(days=1),
'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
'ROTATE_REFRESH_TOKENS': True,
}
REST_AUTH = {
"USE_JWT": True,
'JWT_AUTH_RETURN_EXPIRATION': True,
'JWT_AUTH_HTTPONLY': False,
}
AUTH_USER_MODEL = 'user.User'
# Password validation

View File

@ -12,6 +12,7 @@ DATABASES = {"default": DEFAULT_CONNECTION, }
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.environ.get("SECRET_KEY")
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

View File

@ -27,12 +27,12 @@ from user.views import (UserViewSet,
)
from qrtr_account.views import (AccountViewSet,
BankViewSet,
BankAccountViewSet,
InstitutionViewSet,
TransactionViewSet,
SliceViewSet,
SliceViewSet, SliceTransactionViewSet,
FacebookLogin,
TwitterLogin)
TwitterLogin, SubscriptionPlanViewSet)
from connection.views import ConnectionViewSet, ConnectionTypeViewSet
@ -59,12 +59,15 @@ router = routers.DefaultRouter()
router.register(r'users', UserViewSet)
router.register(r'groups', GroupViewSet)
router.register(r'accounts', AccountViewSet)
router.register(r'banks', BankViewSet)
router.register(r'bank-accounts', BankAccountViewSet)
router.register(r'institutions', InstitutionViewSet)
router.register(r'transactions', TransactionViewSet)
router.register(r'slices', SliceViewSet)
#router.register(r'connections',ConnectionViewSet)
router.register(r'slices/(?P<slice_pk>\d+)/transactions',
SliceTransactionViewSet, basename='slices')
router.register(r'connections',ConnectionViewSet)
router.register(r'connectiontypes', ConnectionTypeViewSet)
router.register(r'plans', SubscriptionPlanViewSet)
# Wire up our API using automatic URL routing.
# Additionally, we include login URLs for the browsable API.
@ -83,6 +86,7 @@ apipatterns = [
urlpatterns = [
path('admin/', admin.site.urls),
path('api/v1/', include(apipatterns), name='api'),
path('api-auth/', include('rest_framework.urls')),
# path('api/v1/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/v1/docs', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
path('api/v1/schema/redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),

View File

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Account, Institution, Bank, Transaction, Slice
from .models import Account, Institution, BankAccount, Transaction, Slice, SubscriptionPlan
@admin.register(Account)
@ -12,8 +12,8 @@ class InstitutionAdmin(admin.ModelAdmin):
pass
@admin.register(Bank)
class BankAdmin(admin.ModelAdmin):
@admin.register(BankAccount)
class BankAccountAdmin(admin.ModelAdmin):
pass
@ -25,3 +25,7 @@ class TransactionAdmin(admin.ModelAdmin):
@admin.register(Slice)
class SliceAdmin(admin.ModelAdmin):
pass
@admin.register(SubscriptionPlan)
class SubscriptionPlanAdmin(admin.ModelAdmin):
pass

View File

@ -1,9 +1,9 @@
from qrtr_account.models import Account, Institution, Bank
from qrtr_account.models import Account, Institution, BankAccount
from connection.models import Connection
dummy_ac = Account.objects.all()[0]
dummy_isnt = Institution.objects.all()[0]
dummy_bank = Bank.objects.all()[0]
dummy_bank = BankAccount.objects.all()[0]
conn_dummy = Connection.objects.all()[0]
@ -142,5 +142,5 @@ for account in accounts:
"balance":account.get("balances",{}).get("current"),
"balance_limit":account.get("balances",{}).get("limit")
}
Bank.objects.update_or_create(qrtr_account=dummy_ac, acc_id=account.get("account_id"),
BankAccount.objects.update_or_create(qrtr_account=dummy_ac, acc_id=account.get("account_id"),
defaults=fields)

View File

@ -669,11 +669,11 @@ transactions = [
print(len(transactions))
for transaction in transactions:
bank = Bank.objects.filter(acc_id=transaction.get("account_id",[None]))[0]
bank = BankAccount.objects.filter(acc_id=transaction.get("account_id",[None]))[0]
print(bank)
if bank:
fields = {
"datetime":datetime.strptime(transaction.get("date"),"%Y-%m-%d"),
"details":transaction
}
Transaction.objects.update_or_create(Bank=bank,defaults=fields)
Transaction.objects.update_or_create(BankAccount=bank,defaults=fields)

View File

@ -0,0 +1,29 @@
# Generated by Django 3.2.3 on 2023-07-20 01:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('connection', '0003_auto_20201125_2242'),
('qrtr_account', '0013_auto_20211229_1935'),
]
operations = [
migrations.RenameModel(
old_name='Bank',
new_name='BankAccount',
),
migrations.RenameField(
model_name='slice',
old_name='budget',
new_name='balance',
),
migrations.AlterField(
model_name='transaction',
name='slice',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transactions', to='qrtr_account.slice'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.3 on 2023-07-20 01:15
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('qrtr_account', '0014_auto_20230720_0105'),
]
operations = [
migrations.AlterField(
model_name='bankaccount',
name='institution',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bank_accounts', to='qrtr_account.institution'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.3 on 2023-08-31 02:45
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('qrtr_account', '0015_alter_bankaccount_institution'),
]
operations = [
migrations.AddField(
model_name='slice',
name='bank_acc',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='qrtr_account.bankaccount'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.3 on 2023-08-31 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('qrtr_account', '0016_slice_bank_acc'),
]
operations = [
migrations.AlterField(
model_name='slice',
name='balance',
field=models.DecimalField(blank=True, decimal_places=3, max_digits=100, null=True),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 3.2.3 on 2024-01-18 03:19
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('qrtr_account', '0017_alter_slice_balance'),
]
operations = [
migrations.CreateModel(
name='SubscriptionPlan',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=250)),
('status', models.CharField(choices=[('active', 'Active'), ('inactive', 'Inactive')], max_length=10)),
],
),
migrations.AddField(
model_name='bankaccount',
name='plan',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='qrtr_account.subscriptionplan'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.3 on 2024-08-01 00:55
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('qrtr_account', '0018_auto_20240118_0319'),
]
operations = [
migrations.AddField(
model_name='rule',
name='bank_acc',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='qrtr_account.bankaccount'),
),
]

15
qrtr_account/mixins.py Normal file
View File

@ -0,0 +1,15 @@
from django.db.models import Q
from qrtr_account.models import Account
class OwnedAccountsMixin():
"""Mixin to help getting a list of accounts
the given user is authorized to see
"""
def accessible_accounts(self):
usr = self.request.user
accs = Account.objects.filter(Q(owner=usr) |
Q(id__in=usr.admin_accounts.all().values_list('id')) |
Q(id__in=usr.view_accounts.all().values_list('id')))
return accs

View File

@ -19,7 +19,13 @@ class Account(models.Model):
def __str__(self):
return f"{self.name}"
class SubscriptionPlan(models.Model):
name = models.CharField(max_length=250)
status = models.CharField(choices=[('active','Active'), ('inactive', 'Inactive')], max_length=10)
def __str__(self):
return f"{self.name}"
class Institution(models.Model):
@ -33,12 +39,13 @@ class Institution(models.Model):
return f"{self.name}"
class Bank(models.Model):
class BankAccount(models.Model):
qrtr_account = models.ForeignKey(Account, on_delete=models.CASCADE)
connection = models.ForeignKey('connection.Connection',
on_delete=models.CASCADE)
institution = models.ForeignKey(Institution, on_delete=models.CASCADE,
related_name="banks")
related_name="bank_accounts")
plan = models.ForeignKey(SubscriptionPlan, on_delete=models.SET_NULL, null=True, blank=True)
acc_id = models.CharField(max_length=250, primary_key=True)
nickname = models.CharField(max_length=250)
official_name = models.CharField(max_length=250,blank=True, null=True)
@ -48,6 +55,18 @@ class Bank(models.Model):
ac_subtype = models.CharField(max_length=250, blank=True)
mask = models.CharField(max_length=4,blank=True)
@property
def connection_type(self):
return self.connection.type.name
@property
def plan_name(self):
return self.plan.name
@property
def institution_name(self):
return self.institution.name
@property
def qid(self):
return f"B{self.pk}"
@ -59,7 +78,7 @@ class Bank(models.Model):
class Slice(models.Model):
name = models.CharField(max_length=250)
icon = models.CharField(max_length=250)
budget = models.DecimalField(decimal_places=3, max_digits=100)
balance = models.DecimalField(decimal_places=3, max_digits=100, null=True, blank=True)
description = models.TextField(max_length=255, null=True, blank=True)
avail_parents = models.Q(
app_label='qrtr_account',
@ -71,6 +90,7 @@ class Slice(models.Model):
limit_choices_to=avail_parents,
on_delete=models.CASCADE, null=True, blank=True)
parent_id = models.PositiveIntegerField(null=True)
bank_acc = models.ForeignKey(BankAccount, on_delete=models.CASCADE, null=True, blank=True)
is_unsliced = models.BooleanField(default=False)
slice_of = GenericForeignKey('parent_type', 'parent_id')
@ -88,6 +108,7 @@ class Schedule(models.Model):
class Rule(models.Model):
bank_acc = models.ForeignKey(BankAccount, on_delete=models.CASCADE, null=True, blank=True)
kinds = [("refill", "Refill"), ("increase", "Increase"), ("goal", "Goal")]
kind = models.CharField(choices=kinds, max_length=255)
when_to_run = models.ForeignKey(Schedule, on_delete=models.CASCADE)
@ -114,11 +135,11 @@ class Rule(models.Model):
class Transaction(models.Model):
authorized_date = models.DateField(null=True)
bank = models.ForeignKey(Bank, on_delete=models.CASCADE,
bank = models.ForeignKey(BankAccount, on_delete=models.CASCADE,
related_name='transactions')
name = models.CharField(max_length=255)
details = models.JSONField()
slice = models.ForeignKey(Slice, on_delete=models.SET_NULL, null=True)
slice = models.ForeignKey(Slice, on_delete=models.SET_NULL, null=True, related_name='transactions')
trans_id = models.CharField(max_length=255)
updated_at = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)

View File

@ -1,22 +1,23 @@
from django.shortcuts import render
from rest_framework import viewsets, mixins
from .models import Account, Bank, Institution, Transaction, Slice, Rule
from .models import Account, BankAccount, Institution, Transaction, Slice, Rule, SubscriptionPlan
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import action
from connection.models import Connection, ConnectionType
from api.serializers import (AccountReadSerializer, AccountWriteSerializer,
BankSerializer, BankSerializerPOST,
BankAccountSerializer, BankAccountSerializerPOST,
InstitutionSerializer,
TransactionSerializer,
ConnectionSerializer,
ConnectionTypeSerializer,
SliceSerializer,
RuleSerializer)
SliceSerializer, SliceTransactionSerializer,
RuleSerializer, SubscriptionPlanSerializer)
from allauth.socialaccount.providers.facebook.views import FacebookOAuth2Adapter
from dj_rest_auth.registration.views import SocialLoginView
from allauth.socialaccount.providers.twitter.views import TwitterOAuthAdapter
from dj_rest_auth.social_serializers import TwitterLoginSerializer
from api.mixins import ReadWriteSerializerMixin
from qrtr_account.mixins import OwnedAccountsMixin
class TwitterLogin(SocialLoginView):
@ -28,51 +29,93 @@ class FacebookLogin(SocialLoginView):
adapter_class = FacebookOAuth2Adapter
class AccountViewSet(ReadWriteSerializerMixin, viewsets.ModelViewSet):
class AccountViewSet(ReadWriteSerializerMixin, viewsets.ModelViewSet, OwnedAccountsMixin):
"""API endpoint that allows accounts to be viewed or edited
"""
permission_classes = [IsAuthenticated]
queryset = Account.objects.all()
read_serializer_class = AccountReadSerializer
write_serializer_class = AccountWriteSerializer
def get_queryset(self):
return self.accessible_accounts()
class BankViewSet(viewsets.ModelViewSet):
"""API endpoint that allows Banks to be viewed or edited
class BankAccountViewSet(viewsets.ModelViewSet, OwnedAccountsMixin):
"""API endpoint that allows BankAccounts to be viewed or edited
"""
queryset = Bank.objects.all()
# serializer_class = BankSerializer
permission_classes = [IsAuthenticated]
queryset = BankAccount.objects.all()
# serializer_class = BankAccountSerializer
def get_serializer_class(self):
if self.action == 'create':
return BankSerializerPOST
return BankSerializer
return BankAccountSerializerPOST
return BankAccountSerializer
def get_queryset(self):
return BankAccount.objects.filter(
qrtr_account__in=self.accessible_accounts().values_list('id'))
class SliceViewSet(viewsets.ModelViewSet):
"""API endpoint that allows Banks to be viewed.
class SliceViewSet(viewsets.ModelViewSet, OwnedAccountsMixin):
"""API endpoint that allows BankAccounts to be viewed.
"""
permission_classes = [IsAuthenticated]
queryset = Slice.objects.all()
serializer_class = SliceSerializer
filterset_fields = {
'id': ['exact', 'lte', 'gte'],
'name': ['exact',],
'balance': ['exact', 'lte', 'gte'],
'bank_acc': ['exact'],
# 'slice_of': ['exact']
}
def get_queryset(self):
return Slice.objects.select_related('bank_acc').filter(
bank_acc__qrtr_account__in=self.accessible_accounts().values_list('id')
)
class SubscriptionPlanViewSet(viewsets.ModelViewSet):
queryset = SubscriptionPlan.objects.all()
serializer_class = SubscriptionPlanSerializer
class InstitutionViewSet(viewsets.ReadOnlyModelViewSet):
"""API endpoint that allows Banks to be viewed.
"""API endpoint that allows BankAccounts to be viewed.
"""
permission_classes = [IsAuthenticated]
queryset = Institution.objects.all()
serializer_class = InstitutionSerializer
class TransactionViewSet(viewsets.ModelViewSet):
"""API endpoint that allows Banks to be viewed.
class TransactionViewSet(viewsets.ModelViewSet, OwnedAccountsMixin):
"""API endpoint that allows BankAccounts to be viewed.
"""
permission_classes = [IsAuthenticated]
queryset = Transaction.objects.filter(is_split=False)
serializer_class = TransactionSerializer
search_fields = ['name', 'slice__name', 'bank__nickname',
'bank__official_name']
filterset_fields = {
'slice__id': ['exact',],
'slice__name': ['exact', ],
'authorized_date': ['exact', 'lte', 'gte', 'isnull'],
'updated_at': ['exact', 'lte', 'gte', 'isnull'],
'created_at': ['exact', 'lte', 'gte', 'isnull'],
'trans_id': ['exact', 'lte', 'gte', 'isnull'],
'trans_id': ['exact', 'lte', 'gte'],
'id': ['exact', 'lte', 'gte'],
'bank': ['exact']
}
@action(detail=True, methods=['post'], url_path='split')
@ -91,9 +134,43 @@ class TransactionViewSet(viewsets.ModelViewSet):
child2 = Transaction.objects.create(**base_information)
child2.name = f"{child1.name}.split2"
def get_queryset(self):
return Transaction.objects.select_related('bank').filter(
bank__qrtr_account__in=self.accessible_accounts().values_list('id')
).filter(is_split=False)
class RuleViewSet(viewsets.ReadOnlyModelViewSet):
"""API endpoint that allows Banks to be viewed.
class SliceTransactionViewSet(viewsets.ModelViewSet, OwnedAccountsMixin):
permission_classes = [IsAuthenticated]
serializer_class = SliceTransactionSerializer
queryset = Slice.objects.all()
filterset_fields = {
'id': ['exact', 'lte', 'gte'],
'name': ['exact',],
'balance': ['exact', 'lte', 'gte'],
'bank_acc': ['exact'],
# 'slice_of': ['exact']
}
def get_queryset(self):
return Slice.objects.select_related('bank_acc').filter(
bank_acc__qrtr_account__in=self.accessible_accounts().values_list('id')
)
# def get_queryset(self):
# return Transaction.objects.filter(slice__pk=self.kwargs.get('slice_pk'))
class RuleViewSet(viewsets.ReadOnlyModelViewSet, OwnedAccountsMixin):
"""API endpoint that allows BankAccounts to be viewed.
"""
permission_classes = [IsAuthenticated]
queryset = Rule.objects.all()
serializer_class = RuleSerializer
def get_queryset(self):
return Rule.objects.select_related('bank_acc').filter(
bank_acc__qrtr_account__in=self.accessible_accounts().values_list('id')
)

View File

@ -5,10 +5,10 @@ chardet==4.0.0
cryptography==3.4.7
defusedxml==0.7.1
dj-database-url==0.5.0
dj-rest-auth==2.1.5
dj-rest-auth==3.0.0
django-rest-swagger==2.2.0
Django==3.2.3
django-allauth==0.44.0
django-allauth==0.50.0
django-cors-headers==3.7.0
django-filter==2.4.0
djangorestframework==3.12.4
@ -16,8 +16,8 @@ djangorestframework-simplejwt==4.6.0
drf-yasg==1.20.0
idna==2.10
oauthlib==3.1.0
plaid-python==9.2.0
psycopg2==2.8.6
plaid_python==14.0.0
psycopg2-binary==2.8.6
pycparser==2.20
PyJWT==2.1.0
python3-openid==3.2.0