Fetch account details and display

This commit is contained in:
Mark Veidemanis 2023-03-09 16:44:16 +00:00
parent de04f8d29b
commit bcfa8f61e1
Signed by: m
GPG Key ID: 5ACFCEED46C0904F
14 changed files with 362 additions and 63 deletions

View File

@ -20,7 +20,7 @@ from django.contrib.auth.views import LogoutView
from django.urls import include, path from django.urls import include, path
from two_factor.urls import urlpatterns as tf_urls from two_factor.urls import urlpatterns as tf_urls
from core.views import aggregators, base, notifications from core.views import aggregators, banks, base, notifications
# from core.views.stripe_callbacks import Callback # from core.views.stripe_callbacks import Callback
@ -93,4 +93,16 @@ urlpatterns = [
aggregators.ReqInfo.as_view(), aggregators.ReqInfo.as_view(),
name="req_info", name="req_info",
), ),
# Request bank fetch
path(
"ops/bank_fetch/<str:pk>/",
aggregators.RequestBankFetch.as_view(),
name="bank_fetch",
),
# Bank details by currency
path(
"banks/<str:type>/details/",
banks.BanksCurrencies.as_view(),
name="currencies",
),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@ -0,0 +1,46 @@
from abc import ABC
class AggregatorClient(ABC):
def store_account_info(self, account_infos):
print("STORE ACCOUNT INFO CALLED")
# account_infos = {
# bank: accounts
# for bank, accounts in account_info.items()
# for account in accounts
# #if account["account_id"] in self.banks
# }
# For each bank
for bank, accounts in account_infos.items():
# Iterate the accounts
for index, account in enumerate(list(accounts)):
if "account_number" not in account:
account_infos[bank][index]["account_number"] = {}
fields = ["sort_code", "number", "iban"]
for field in fields:
if field in account:
account_infos[bank][index]["account_number"][
field
] = account[field]
del account_infos[bank][index][field]
# if len(account["account_number"]) == 1:
# account_infos[bank].remove(account)
currencies = [
account["currency"]
for bank, accounts in account_infos.items()
for account in accounts
]
for bank, accounts in account_infos.items():
if not self.instance.account_info:
self.instance.account_info = {}
self.instance.account_info[bank] = []
for account in accounts:
self.instance.account_info[bank].append(account)
# self.account_info = account_infos
self.currencies = currencies
self.instance.currencies = currencies
self.instance.save()
print("CURRENCIES", self.instance.currencies)
print("ACCOUNT INFO", self.instance.account_info)

View File

@ -3,13 +3,14 @@ from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
from core.clients.aggregator import AggregatorClient
from core.clients.base import BaseClient from core.clients.base import BaseClient
from core.util import logs from core.util import logs
log = logs.get_logger("nordigen") log = logs.get_logger("nordigen")
class NordigenClient(BaseClient): class NordigenClient(BaseClient, AggregatorClient):
url = "https://ob.nordigen.com/api/v2" url = "https://ob.nordigen.com/api/v2"
async def connect(self): async def connect(self):
@ -60,7 +61,7 @@ class NordigenClient(BaseClient):
""" """
# This function is a stub. # This function is a stub.
return ["GB", "SE"] return ["GB", "SE", "BG", "UA"]
async def get_banks(self, country): async def get_banks(self, country):
""" """
@ -128,28 +129,31 @@ class NordigenClient(BaseClient):
path = f"accounts/{account_id}/details" path = f"accounts/{account_id}/details"
response = await self.call(path, schema="AccountDetails") response = await self.call(path, schema="AccountDetails")
print("RESPONSE", response)
if "account" not in response: if "account" not in response:
return False return False
parsed = response["account"] parsed = response["account"]
if "bban" in parsed and parsed["currency"] == "GBP": if "iban" in parsed and parsed["currency"] == "GBP":
sort_code = parsed["bban"][0:6] if parsed["iban"]:
account_number = parsed["bban"][6:] sort_code = parsed["iban"][-14:-8]
del parsed["bban"] account_number = parsed["iban"][-8:]
if "iban" in parsed:
del parsed["iban"] del parsed["iban"]
sort_code = "-".join(list(map("".join, zip(*[iter(sort_code)] * 2)))) # if "iban" in parsed:
parsed["sort_code"] = sort_code # del parsed["iban"]
parsed["number"] = account_number sort_code = "-".join(list(map("".join, zip(*[iter(sort_code)] * 2))))
parsed["recipient"] = "TODO" parsed["sort_code"] = sort_code
parsed["number"] = account_number
parsed["recipient"] = "TODO"
# Let's add the account ID so we can reference it later # Let's add the account ID so we can reference it later
parsed["account_id"] = account_id parsed["account_id"] = account_id
return parsed return parsed
async def get_all_account_info(self, requisition=None): async def get_all_account_info(self, requisition=None, store=False):
to_return = {} to_return = {}
if not requisition: if not requisition:
raise NotImplementedError print("NOT REQUISITION")
# requisitions = await self.get_requisitions() requisitions = await self.get_requisitions()
print("GOT REQS", requisitions)
else: else:
requisitions = [await self.get_requisition(requisition)] requisitions = [await self.get_requisition(requisition)]
@ -163,4 +167,8 @@ class NordigenClient(BaseClient):
to_return[req["institution_id"]].append(account_info) to_return[req["institution_id"]].append(account_info)
else: else:
to_return[req["institution_id"]] = [account_info] to_return[req["institution_id"]] = [account_info]
if store:
print("TO STORE IS TRUE")
self.store_account_info(to_return)
return to_return return to_return

View File

@ -113,9 +113,12 @@ class AccountDetailsNested(BaseModel):
currency: str currency: str
ownerName: str ownerName: str
cashAccountType: str cashAccountType: str
status: str status: str | None
maskedPan: str | None maskedPan: str | None
details: str details: str | None
iban: str | None
name: str | None
product: str | None
class AccountDetails(BaseModel): class AccountDetails(BaseModel):

View File

@ -3,6 +3,8 @@ import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from core.clients.aggregators.nordigen import NordigenClient
from core.models import Aggregator
from core.util import logs from core.util import logs
log = logs.get_logger("scheduling") log = logs.get_logger("scheduling")
@ -12,6 +14,17 @@ INTERVAL = 5
async def job(): async def job():
print("Running schedule.") print("Running schedule.")
aggregators = Aggregator.objects.filter(enabled=True, fetch_accounts=True)
for aggregator in aggregators:
if aggregator.service == "nordigen":
instance = await NordigenClient(aggregator)
print("RUNNING GET ALL ACCOUNT INFO")
await instance.get_all_account_info(store=True)
print("FINISHED RUNNING")
else:
raise NotImplementedError(f"No such client library: {aggregator.service}")
aggregator.fetch_accounts = False
aggregator.save()
class Command(BaseCommand): class Command(BaseCommand):

View File

@ -0,0 +1,23 @@
# Generated by Django 4.1.7 on 2023-03-09 11:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_aggregator_access_token_expires'),
]
operations = [
migrations.AddField(
model_name='aggregator',
name='account_info',
field=models.JSONField(default=list),
),
migrations.AddField(
model_name='aggregator',
name='currencies',
field=models.JSONField(default=list),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-03-09 11:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_aggregator_account_info_aggregator_currencies'),
]
operations = [
migrations.AddField(
model_name='aggregator',
name='fetch_accounts',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.1.7 on 2023-03-09 14:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0006_aggregator_fetch_accounts'),
]
operations = [
migrations.AlterField(
model_name='aggregator',
name='account_info',
field=models.JSONField(default=dict),
),
migrations.AlterField(
model_name='aggregator',
name='fetch_accounts',
field=models.BooleanField(default=True),
),
]

View File

@ -45,6 +45,11 @@ class Aggregator(models.Model):
access_token_expires = models.DateTimeField(null=True, blank=True) access_token_expires = models.DateTimeField(null=True, blank=True)
poll_interval = models.IntegerField(default=10) poll_interval = models.IntegerField(default=10)
account_info = models.JSONField(default=dict)
currencies = models.JSONField(default=list)
fetch_accounts = models.BooleanField(default=True)
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
def __str__(self): def __str__(self):

View File

@ -219,6 +219,40 @@
Home Home
</a> </a>
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a class="navbar-item" href="#">
Profit
</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
Banks
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="{% url 'currencies' type='page' %}">
Currencies
</a>
<a class="navbar-item" href="#">
Balances
</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
Platforms
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="#">
Advert Configuration
</a>
<a class="navbar-item" href="#">
Advert Management
</a>
<a class="navbar-item" href="#">
Market Research
</a>
</div>
</div>
<div class="navbar-item has-dropdown is-hoverable"> <div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link"> <a class="navbar-link">
Setup Setup

View File

@ -0,0 +1,50 @@
{% load cache %}
{% load cachalot cache %}
{% load nsep %}
{% get_last_invalidation 'core.Aggregator' as last %}
{% include 'mixins/partials/notify.html' %}
{# cache 600 objects_banks_currencies request.user.id object_list type last #}
{% for bank, accounts in object_list.items %}
<h1 class="title is-4">{{ bank }}</h1>
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ bank }}-table"
id="{{ bank }}-table"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>currency</th>
<th>owner</th>
<th>details</th>
<th>payment</th>
<th>id</th>
</thead>
{% for account in accounts %}
<tr>
<td>{{ account.currency }}</td>
<td>{{ account.ownerName }}</td>
<td>{{ account.details|default_if_none:"—" }}</td>
<td>
{% for item in account.account_number.values %}
<code>{{ item|default_if_none:"—" }}</code>
{% endfor %}
</td>
<td>
<a
class="has-text-grey"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.account_id }}/');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
</tr>
{% endfor %}
</table>
{% endfor %}
{# endcache #}

View File

@ -1,7 +1,7 @@
import asyncio
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render
from django.urls import reverse
from django.views import View from django.views import View
from mixins.views import ( from mixins.views import (
ObjectCreate, ObjectCreate,
@ -16,25 +16,36 @@ from core.clients.aggregators.nordigen import NordigenClient
from core.forms import AggregatorForm from core.forms import AggregatorForm
from core.models import Aggregator from core.models import Aggregator
from core.util import logs from core.util import logs
from core.views.helpers import synchronize_async_helper
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
def synchronize_async_helper(to_await): class RequestBankFetch(LoginRequiredMixin, OTPRequiredMixin, View):
async_response = [] template_name = "mixins/partials/notify.html"
async def run_and_capture_result(): def get(self, request, pk=None):
r = await to_await if pk:
async_response.append(r) try:
aggregator = Aggregator.get_by_id(pk, self.request.user)
aggregators = [aggregator]
try: except Aggregator.DoesNotExist:
loop = asyncio.get_event_loop() message = "Aggregator does not exist"
except RuntimeError: context = {
loop = asyncio.new_event_loop() "message": message,
asyncio.set_event_loop(loop) "class": "danger",
coroutine = run_and_capture_result() }
loop.run_until_complete(coroutine) return self.render_to_response(context)
return async_response[0] else:
aggregators = Aggregator.objects.filter(user=self.request.user)
for agg in aggregators:
agg.fetch_accounts = True
agg.save()
context = {"class": "success", "message": "Fetch requested"}
return render(request, self.template_name, context)
class ReqsList(LoginRequiredMixin, OTPRequiredMixin, ObjectList): class ReqsList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
@ -52,7 +63,17 @@ class ReqsList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
def get_context_data(self): def get_context_data(self):
context = super().get_context_data() context = super().get_context_data()
context["pk"] = self.kwargs.get("pk") pk = self.kwargs.get("pk")
context["pk"] = pk
self.extra_buttons = [
{
"url": reverse("bank_fetch", kwargs={"pk": pk}),
"action": "refresh",
"method": "get",
"label": "Fetch account details",
"icon": "fa-solid fa-refresh",
},
]
return context return context
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
@ -61,18 +82,16 @@ class ReqsList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
aggregator = Aggregator.get_by_id(pk, self.request.user) aggregator = Aggregator.get_by_id(pk, self.request.user)
except Aggregator.DoesNotExist: except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
message_class = "danger"
context = { context = {
"message": message, "message": "Aggregator does not exist",
"message_class": message_class, "class": "danger",
"window_content": self.window_content,
} }
return self.render_to_response(context) return self.render_to_response(context)
self.page_title = ( self.page_title = (
f"Requisitions for {aggregator.name} ({aggregator.get_service_display()})" f"Requisitions for {aggregator.name} ({aggregator.get_service_display()})"
) )
self.page_subtitle = f"Stored account details: {len(aggregator.currencies)}"
run = synchronize_async_helper(NordigenClient(aggregator)) run = synchronize_async_helper(NordigenClient(aggregator))
reqs = synchronize_async_helper(run.get_requisitions()) reqs = synchronize_async_helper(run.get_requisitions())
@ -100,12 +119,9 @@ class AggregatorCountriesList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
aggregator = Aggregator.get_by_id(pk, self.request.user) aggregator = Aggregator.get_by_id(pk, self.request.user)
except Aggregator.DoesNotExist: except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
message_class = "danger"
context = { context = {
"message": message, "message": "Aggregator does not exist",
"message_class": message_class, "class": "danger",
"window_content": self.window_content,
} }
return self.render_to_response(context) return self.render_to_response(context)
@ -141,12 +157,9 @@ class AggregatorCountryBanksList(LoginRequiredMixin, OTPRequiredMixin, ObjectLis
aggregator = Aggregator.get_by_id(pk, self.request.user) aggregator = Aggregator.get_by_id(pk, self.request.user)
except Aggregator.DoesNotExist: except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
message_class = "danger"
context = { context = {
"message": message, "message": "Aggregator does not exist",
"message_class": message_class, "class": "danger",
"window_content": self.window_content,
} }
return self.render_to_response(context) return self.render_to_response(context)
@ -167,12 +180,9 @@ class AggregatorLinkBank(LoginRequiredMixin, OTPRequiredMixin, View):
aggregator = Aggregator.get_by_id(pk, self.request.user) aggregator = Aggregator.get_by_id(pk, self.request.user)
except Aggregator.DoesNotExist: except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
message_class = "danger"
context = { context = {
"message": message, "message": "Aggregator does not exist",
"message_class": message_class, "class": "danger",
"window_content": self.window_content,
} }
return self.render_to_response(context) return self.render_to_response(context)
run = synchronize_async_helper(NordigenClient(aggregator)) run = synchronize_async_helper(NordigenClient(aggregator))
@ -192,12 +202,9 @@ class ReqDelete(LoginRequiredMixin, OTPRequiredMixin, View):
aggregator = Aggregator.get_by_id(pk, self.request.user) aggregator = Aggregator.get_by_id(pk, self.request.user)
except Aggregator.DoesNotExist: except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
message_class = "danger"
context = { context = {
"message": message, "message": "Aggregator does not exist",
"message_class": message_class, "class": "danger",
"window_content": self.window_content,
} }
return self.render_to_response(context) return self.render_to_response(context)
run = synchronize_async_helper(NordigenClient(aggregator)) run = synchronize_async_helper(NordigenClient(aggregator))
@ -219,12 +226,9 @@ class ReqInfo(LoginRequiredMixin, OTPRequiredMixin, ObjectRead):
aggregator = Aggregator.get_by_id(pk, self.request.user) aggregator = Aggregator.get_by_id(pk, self.request.user)
except Aggregator.DoesNotExist: except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
message_class = "danger"
context = { context = {
"message": message, "message": "Aggregator does not exist",
"message_class": message_class, "class": "danger",
"window_content": self.window_content,
} }
return self.render_to_response(context) return self.render_to_response(context)
run = synchronize_async_helper(NordigenClient(aggregator)) run = synchronize_async_helper(NordigenClient(aggregator))

42
core/views/banks.py Normal file
View File

@ -0,0 +1,42 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectList, ObjectRead
from two_factor.views.mixins import OTPRequiredMixin
from core.models import Aggregator
from core.util import logs
log = logs.get_logger(__name__)
class BanksCurrencies(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
"""
Get a list of configured currencies from the banks we use.
"""
list_template = "partials/banks-currencies-list.html"
page_title = "Bank Currencies"
context_object_name_singular = "currency"
context_object_name = "currencies"
list_url_name = "currencies"
list_url_args = ["type"]
def get_queryset(self, **kwargs):
aggregators = Aggregator.objects.filter(user=self.request.user, enabled=True)
account_info = {}
for agg in aggregators:
for bank, accounts in agg.account_info.items():
if bank not in account_info:
account_info[bank] = []
for account in accounts:
account_info[bank].append(account)
return account_info
class BankCurrencyDetails(LoginRequiredMixin, OTPRequiredMixin, ObjectRead):
"""
Get the bank details for the selected currency.
"""

18
core/views/helpers.py Normal file
View File

@ -0,0 +1,18 @@
import asyncio
def synchronize_async_helper(to_await):
async_response = []
async def run_and_capture_result():
r = await to_await
async_response.append(r)
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
coroutine = run_and_capture_result()
loop.run_until_complete(coroutine)
return async_response[0]