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 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
@ -93,4 +93,16 @@ urlpatterns = [
aggregators.ReqInfo.as_view(),
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)

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

View File

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

View File

@ -3,6 +3,8 @@ import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from django.core.management.base import BaseCommand
from core.clients.aggregators.nordigen import NordigenClient
from core.models import Aggregator
from core.util import logs
log = logs.get_logger("scheduling")
@ -12,6 +14,17 @@ INTERVAL = 5
async def job():
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):

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)
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)
def __str__(self):

View File

@ -219,6 +219,40 @@
Home
</a>
{% 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">
<a class="navbar-link">
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.http import HttpResponse
from django.shortcuts import render
from django.urls import reverse
from django.views import View
from mixins.views import (
ObjectCreate,
@ -16,25 +16,36 @@ from core.clients.aggregators.nordigen import NordigenClient
from core.forms import AggregatorForm
from core.models import Aggregator
from core.util import logs
from core.views.helpers import synchronize_async_helper
log = logs.get_logger(__name__)
def synchronize_async_helper(to_await):
async_response = []
class RequestBankFetch(LoginRequiredMixin, OTPRequiredMixin, View):
template_name = "mixins/partials/notify.html"
async def run_and_capture_result():
r = await to_await
async_response.append(r)
def get(self, request, pk=None):
if pk:
try:
aggregator = Aggregator.get_by_id(pk, self.request.user)
aggregators = [aggregator]
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]
except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
context = {
"message": message,
"class": "danger",
}
return self.render_to_response(context)
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):
@ -52,7 +63,17 @@ class ReqsList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
def get_context_data(self):
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
def get_queryset(self, **kwargs):
@ -61,18 +82,16 @@ class ReqsList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
aggregator = Aggregator.get_by_id(pk, self.request.user)
except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
message_class = "danger"
context = {
"message": message,
"message_class": message_class,
"window_content": self.window_content,
"message": "Aggregator does not exist",
"class": "danger",
}
return self.render_to_response(context)
self.page_title = (
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))
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)
except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
message_class = "danger"
context = {
"message": message,
"message_class": message_class,
"window_content": self.window_content,
"message": "Aggregator does not exist",
"class": "danger",
}
return self.render_to_response(context)
@ -141,12 +157,9 @@ class AggregatorCountryBanksList(LoginRequiredMixin, OTPRequiredMixin, ObjectLis
aggregator = Aggregator.get_by_id(pk, self.request.user)
except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
message_class = "danger"
context = {
"message": message,
"message_class": message_class,
"window_content": self.window_content,
"message": "Aggregator does not exist",
"class": "danger",
}
return self.render_to_response(context)
@ -167,12 +180,9 @@ class AggregatorLinkBank(LoginRequiredMixin, OTPRequiredMixin, View):
aggregator = Aggregator.get_by_id(pk, self.request.user)
except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
message_class = "danger"
context = {
"message": message,
"message_class": message_class,
"window_content": self.window_content,
"message": "Aggregator does not exist",
"class": "danger",
}
return self.render_to_response(context)
run = synchronize_async_helper(NordigenClient(aggregator))
@ -192,12 +202,9 @@ class ReqDelete(LoginRequiredMixin, OTPRequiredMixin, View):
aggregator = Aggregator.get_by_id(pk, self.request.user)
except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
message_class = "danger"
context = {
"message": message,
"message_class": message_class,
"window_content": self.window_content,
"message": "Aggregator does not exist",
"class": "danger",
}
return self.render_to_response(context)
run = synchronize_async_helper(NordigenClient(aggregator))
@ -219,12 +226,9 @@ class ReqInfo(LoginRequiredMixin, OTPRequiredMixin, ObjectRead):
aggregator = Aggregator.get_by_id(pk, self.request.user)
except Aggregator.DoesNotExist:
message = "Aggregator does not exist"
message_class = "danger"
context = {
"message": message,
"message_class": message_class,
"window_content": self.window_content,
"message": "Aggregator does not exist",
"class": "danger",
}
return self.render_to_response(context)
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]