Implement adding bank links

This commit is contained in:
Mark Veidemanis 2023-03-08 12:48:05 +00:00
parent 3699fff272
commit 479e5b1022
Signed by: m
GPG Key ID: 5ACFCEED46C0904F
16 changed files with 764 additions and 39 deletions

View File

@ -59,4 +59,26 @@ urlpatterns = [
aggregators.AggregatorDelete.as_view(), aggregators.AggregatorDelete.as_view(),
name="aggregator_delete", name="aggregator_delete",
), ),
# Aggregator Requisitions
path(
"aggs/<str:type>/info/<str:pk>/",
aggregators.ReqsList.as_view(),
name="reqs",
),
# Aggregator Account link flow
path(
"aggs/<str:type>/countries/<str:pk>/",
aggregators.AggregatorCountriesList.as_view(),
name="aggregator_countries",
),
path(
"aggs/<str:type>/countries/<str:pk>/<str:country>/banks/",
aggregators.AggregatorCountryBanksList.as_view(),
name="aggregator_country_banks",
),
path(
"aggs/<str:type>/link/<str:pk>/<str:bank>/",
aggregators.AggregatorLinkBank.as_view(),
name="aggregator_link",
),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@ -1,8 +1,6 @@
import os import os
# import stripe # import stripe
from django.conf import settings
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
# from redis import StrictRedis # from redis import StrictRedis

0
core/clients/__init__.py Normal file
View File

View File

@ -0,0 +1,99 @@
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from core.clients.base import BaseClient
from core.util import logs
log = logs.get_logger("nordigen")
class NordigenClient(BaseClient):
url = "https://ob.nordigen.com/api/v2"
async def connect(self):
now = timezone.now()
# Check if access token expires later than now
if self.instance.access_token_expires is not None:
if self.instance.access_token_expires > now:
self.token = self.instance.access_token
return
await self.get_access_token()
def method_filter(self, method):
new_method = method.replace("/", "_")
return new_method
async def get_access_token(self):
"""
Get the access token for the Nordigen API.
"""
log.debug(f"Getting new access token for {self.instance}")
data = {
"secret_id": self.instance.secret_id,
"secret_key": self.instance.secret_key,
}
response = await self.call("token/new", http_method="post", data=data)
print("RESPONSE IN GET ACCESS TOKEN", response) #
access = response["access"]
access_expires = response["access_expires"]
print("ACCESS EXPIRES", access_expires)
now = timezone.now()
# Offset now by access_expires seconds
access_expires = now + timedelta(seconds=access_expires)
print("ACCESS EXPIRES", access_expires)
self.instance.access_token = access
self.instance.access_token_expires = access_expires
self.instance.save()
self.token = access
async def get_requisitions(self):
"""
Get a list of active accounts.
"""
response = await self.call("requisitions")
return response["results"]
async def get_countries(self):
"""
Get a list of countries.
"""
# This function is a stub.
return ["GB", "SE"]
async def get_banks(self, country):
"""
Get a list of supported banks for a country.
:param country: country to query
:return: list of institutions
:rtype: list
"""
if not len(country) == 2:
return False
path = f"institutions/?country={country}"
response = await self.call(path, schema="Institutions", append_slash=False)
return response
async def build_link(self, institution_id, redirect=None):
"""Create a link to access an institution.
:param institution_id: ID of the institution
"""
data = {
"institution_id": institution_id,
"redirect": settings.URL,
}
if redirect:
data["redirect"] = redirect
response = await self.call(
"requisitions", schema="RequisitionsPost", http_method="post", data=data
)
print("build_link response", response)
if "link" in response:
return response["link"]
return False

206
core/clients/base.py Normal file
View File

@ -0,0 +1,206 @@
from abc import ABC, abstractmethod
import aiohttp
import orjson
from glom import glom
from pydantic.error_wrappers import ValidationError
from core.lib import schemas
from core.util import logs
# Return error if the schema for the message type is not found
STRICT_VALIDATION = False
# Raise exception if the conversion schema is not found
STRICT_CONVERSION = False
# TODO: Set them to True when all message types are implemented
log = logs.get_logger("clients")
class NoSchema(Exception):
"""
Raised when:
- The schema for the message type is not found
- The conversion schema is not found
- There is no schema library for the client
"""
pass
class NoSuchMethod(Exception):
"""
Client library has no such method.
"""
pass
class GenericAPIError(Exception):
"""
Generic API error.
"""
pass
def is_camel_case(s):
return s != s.lower() and s != s.upper() and "_" not in s
def snake_to_camel(word):
if is_camel_case(word):
return word
return "".join(x.capitalize() or "_" for x in word.split("_"))
DEFAULT_HEADERS = {
"accept": "application/json",
"Content-Type": "application/json",
}
class BaseClient(ABC):
token = None
async def __new__(cls, *a, **kw):
instance = super().__new__(cls)
await instance.__init__(*a, **kw)
return instance
async def __init__(self, instance):
"""
Initialise the client.
:param instance: the database object, e.g. Aggregator
"""
name = self.__class__.__name__
self.name = name.replace("Client", "").lower()
self.instance = instance
self.client = None
await self.connect()
@abstractmethod
async def connect(self):
pass
@property
def schema(self):
"""
Get the schema library for the client.
"""
# Does the schemas library have a library for this client name?
if hasattr(schemas, f"{self.name}_s"):
schema_instance = getattr(schemas, f"{self.name}_s")
else:
log.error(f"No schema library for {self.name}")
raise Exception(f"No schema library for client {self.name}")
return schema_instance
def get_schema(self, method, convert=False):
if isinstance(method, str):
to_camel = snake_to_camel(method)
else:
to_camel = snake_to_camel(method.__class__.__name__)
if convert:
to_camel = f"{to_camel}Schema"
# if hasattr(self.schema, method):
# schema = getattr(self.schema, method)
if hasattr(self.schema, to_camel):
schema = getattr(self.schema, to_camel)
else:
raise NoSchema(f"Could not get schema: {to_camel}")
return schema
async def call_method(self, method, *args, **kwargs):
"""
Call a method with aiohttp.
"""
if kwargs.get("append_slash", True):
path = f"{self.url}/{method}/"
else:
path = f"{self.url}/{method}"
http_method = kwargs.get("http_method", "get")
cast = {
"headers": DEFAULT_HEADERS,
}
print("TOKEN", self.token)
# Use the token if it's set
if self.token is not None:
cast["headers"]["Authorization"] = f"Bearer {self.token}"
if "data" in kwargs:
cast["data"] = orjson.dumps(kwargs["data"])
# Use the method to send a HTTP request
async with aiohttp.ClientSession() as session:
session_method = getattr(session, http_method)
async with session_method(path, **cast) as response:
response_json = await response.json()
return response_json
def convert_spec(self, response, method):
"""
Convert an API response to the requested spec.
:raises NoSchema: If the conversion schema is not found
"""
schema = self.get_schema(method, convert=True)
# Use glom to convert the response to the schema
converted = glom(response, schema)
return converted
def validate_response(self, response, method):
schema = self.get_schema(method)
# Return a dict of the validated response
try:
response_valid = schema(**response).dict()
except ValidationError as e:
log.error(f"Error validating {method} response: {response}")
log.error(f"Errors: {e}")
raise GenericAPIError("Error validating response")
return response_valid
def method_filter(self, method):
"""
Return a new method.
"""
return method
async def call(self, method, *args, **kwargs):
"""
Call the exchange API and validate the response
:raises NoSchema: If the method is not in the schema mapping
:raises ValidationError: If the response cannot be validated
"""
# try:
response = await self.call_method(method, *args, **kwargs)
# except (APIError, V20Error) as e:
# log.error(f"Error calling method {method}: {e}")
# raise GenericAPIError(e)
if "schema" in kwargs:
method = kwargs["schema"]
else:
method = self.method_filter(method)
try:
response_valid = self.validate_response(response, method)
except NoSchema as e:
log.error(f"{e} - {response}")
response_valid = response
# Convert the response to a format that we can use
try:
response_converted = self.convert_spec(response_valid, method)
except NoSchema as e:
log.error(f"{e} - {response}")
response_converted = response_valid
# return (True, response_converted)
return response_converted

View File

@ -0,0 +1 @@
from core.lib.schemas import nordigen_s # noqa

View File

@ -0,0 +1,77 @@
from pydantic import BaseModel
class TokenNew(BaseModel):
access: str
access_expires: int
refresh: str
refresh_expires: int
TokenNewSchema = {
"access": "access",
"access_expires": "access_expires",
"refresh": "refresh",
"refresh_expires": "refresh_expires",
}
class RequisitionResult(BaseModel):
id: str
created: str
redirect: str
status: str
institution_id: str
agreement: str
reference: str
accounts: list[str]
link: str
ssn: str | None
account_selection: bool
redirect_immediate: bool
class Requisitions(BaseModel):
count: int
next: str | None
previous: str | None
results: list[RequisitionResult]
RequisitionsSchema = {
"count": "count",
"next": "next",
"previous": "previous",
"results": "results",
}
class RequisitionsPost(BaseModel):
id: str
created: str
redirect: str
status: str
institution_id: str
agreement: str
reference: str
accounts: list[str]
link: str
ssn: str | None
account_selection: bool
redirect_immediate: bool
RequisitionsPostSchema = {
"id": "id",
"created": "created",
"redirect": "redirect",
"status": "status",
"institution_id": "institution_id",
"agreement": "agreement",
"reference": "reference",
"accounts": "accounts",
"link": "link",
"ssn": "ssn",
"account_selection": "account_selection",
"redirect_immediate": "redirect_immediate",
}

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-03-08 10:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_aggregator_enabled'),
]
operations = [
migrations.AddField(
model_name='aggregator',
name='access_token_expires',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -42,6 +42,18 @@ class Aggregator(models.Model):
secret_id = models.CharField(max_length=1024, null=True, blank=True) secret_id = models.CharField(max_length=1024, null=True, blank=True)
secret_key = models.CharField(max_length=1024, null=True, blank=True) secret_key = models.CharField(max_length=1024, null=True, blank=True)
access_token = models.CharField(max_length=1024, null=True, blank=True) access_token = models.CharField(max_length=1024, 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)
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
def __str__(self):
return f"Aggregator ({self.service}) for {self.user}"
@classmethod
def get_by_id(cls, obj_id, user):
return cls.objects.get(id=obj_id, user=user)
@property
def client(self):
pass

View File

@ -0,0 +1,36 @@
{% include 'mixins/partials/notify.html' %}
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
id="{{ context_object_name }}-table"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>country</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr>
<td> {{ item }}</td>
<td>
<div class="buttons">
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'aggregator_country_banks' type=type pk=pk country=item %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-eye"></i>
</span>
</span>
</button>
</div>
</td>
</tr>
{% endfor %}
</table>

View File

@ -0,0 +1,38 @@
{% include 'mixins/partials/notify.html' %}
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
id="{{ context_object_name }}-table"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>name</th>
<th>logo</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr>
<td>{{ item.name }}</td>
<td><img src="{{ item.logo }}" width="35" height="35"></td>
<td>
<div class="buttons">
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{% url 'aggregator_link' type=type pk=pk bank=item.id %}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-link"></i>
</span>
</span>
</button>
</div>
</td>
</tr>
{% endfor %}
</table>

View File

@ -0,0 +1,75 @@
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
id="{{ context_object_name }}-table"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>id</th>
<th>created</th>
<th>institution</th>
<th>accounts</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr>
<td>
<a
class="has-text-grey"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.id }}/');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
</td>
<td>{{ item.created }}</td>
<td>{{ item.institution_id }}</td>
<td>{{ item.accounts }}</td>
<td>
<div class="buttons">
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{# url 'aggregator_delete' type=type pk=item.id #}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
hx-confirm="Are you sure you wish to delete {{ item.id }}?"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
</span>
</button>
{% if type == 'page' %}
<a href="{# url 'aggregator_read' type=type pk=item.id #}"><button
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-eye"></i>
</span>
</span>
</button>
</a>
{% else %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{# url 'aggregator_info' type=type pk=item.id #}"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-eye"></i>
</span>
</span>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</table>

View File

@ -1,8 +1,8 @@
{% load cache %} {% load cache %}
{% load cachalot cache %} {% load cachalot cache %}
{% get_last_invalidation 'core.Hook' as last %} {% get_last_invalidation 'core.Aggregator' as last %}
{% include 'mixins/partials/notify.html' %} {% include 'mixins/partials/notify.html' %}
{# cache 600 objects_hooks request.user.id object_list type last #} {# cache 600 objects_aggregators request.user.id object_list type last #}
<table <table
class="table is-fullwidth is-hoverable" class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table" hx-target="#{{ context_object_name }}-table"
@ -30,7 +30,7 @@
</a> </a>
</td> </td>
<td>{{ item.user }}</td> <td>{{ item.user }}</td>
<td>{{ item.name }}</td> <td><a href="{% url 'reqs' type='page' pk=item.id %}">{{ item.name }}</a></td>
<td>{{ item.get_service_display }}</td> <td>{{ item.get_service_display }}</td>
<td> <td>
{% if item.enabled %} {% if item.enabled %}
@ -72,31 +72,6 @@
</span> </span>
</span> </span>
</button> </button>
{% if type == 'page' %}
<a href="#"><button
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-eye"></i>
</span>
</span>
</button>
</a>
{% else %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="#"
hx-trigger="click"
hx-target="#{{ type }}s-here"
hx-swap="innerHTML"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-eye"></i>
</span>
</span>
</button>
{% endif %}
</div> </div>
</td> </td>
</tr> </tr>

View File

@ -1,12 +1,12 @@
import asyncio
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ( # ObjectRead, from django.http import HttpResponse
ObjectCreate, from django.views import View
ObjectDelete, from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
ObjectList,
ObjectUpdate,
)
from two_factor.views.mixins import OTPRequiredMixin from two_factor.views.mixins import OTPRequiredMixin
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
@ -14,6 +14,171 @@ from core.util import logs
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
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]
class ReqsList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
list_template = "partials/aggregator-info.html"
page_title = "Aggregator Info"
context_object_name_singular = "account link"
context_object_name = "account links"
list_url_name = "reqs"
list_url_args = ["type", "pk"]
submit_url_name = "aggregator_countries"
submit_url_args = ["type", "pk"]
def get_queryset(self, **kwargs):
pk = kwargs.get("pk")
try:
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,
}
return self.render_to_response(context)
self.page_title = (
f"Requisitions for {aggregator.name} ({aggregator.get_service_display()})"
)
run = synchronize_async_helper(NordigenClient(aggregator))
reqs = synchronize_async_helper(run.get_requisitions())
print("REQS", reqs)
return reqs
class AggregatorCountriesList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
list_template = "partials/aggregator-countries.html"
page_title = "List of countries"
list_url_name = "aggregator_countries"
list_url_args = ["type", "pk"]
context_object_name_singular = "country"
context_object_name = "countries"
def get_context_data(self):
context = super().get_context_data()
context["pk"] = self.kwargs.get("pk")
return context
def get_queryset(self, **kwargs):
pk = kwargs.get("pk")
try:
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,
}
return self.render_to_response(context)
self.page_title = (
f"Countries for {aggregator.name} ({aggregator.get_service_display()})"
)
run = synchronize_async_helper(NordigenClient(aggregator))
countries = synchronize_async_helper(run.get_countries())
print("COUNTRIES", countries)
self.extra_args = {"pk": pk}
return countries
class AggregatorCountryBanksList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
list_template = "partials/aggregator-country-banks.html"
page_title = "List of banks"
list_url_name = "aggregator_country_banks"
list_url_args = ["type", "pk", "country"]
context_object_name_singular = "bank"
context_object_name = "banks"
def get_context_data(self):
context = super().get_context_data()
context["pk"] = self.kwargs.get("pk")
context["country"] = self.kwargs.get("country")
return context
def get_queryset(self, **kwargs):
pk = kwargs.get("pk")
country = kwargs.get("country")
try:
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,
}
return self.render_to_response(context)
self.page_title = (
f"Banks for {aggregator.name} in {country} "
f"({aggregator.get_service_display()})"
)
run = synchronize_async_helper(NordigenClient(aggregator))
banks = synchronize_async_helper(run.get_banks(country))
print("BANKS", banks)
return banks
class AggregatorLinkBank(LoginRequiredMixin, OTPRequiredMixin, View):
def get(self, request, *args, **kwargs):
pk = kwargs.get("pk")
bank = kwargs.get("bank")
try:
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,
}
return self.render_to_response(context)
run = synchronize_async_helper(NordigenClient(aggregator))
auth_url = synchronize_async_helper(run.build_link(bank))
# Redirect to auth url
print("AUTH URL", auth_url)
# Create a blank response
response = HttpResponse()
response["HX-Redirect"] = auth_url
# return redirect(auth_url)
return response
class AggregatorList(LoginRequiredMixin, OTPRequiredMixin, ObjectList): class AggregatorList(LoginRequiredMixin, OTPRequiredMixin, ObjectList):
list_template = "partials/aggregator-list.html" list_template = "partials/aggregator-list.html"
model = Aggregator model = Aggregator

View File

@ -3,8 +3,8 @@ import logging
# import stripe # import stripe
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.shortcuts import redirect, render from django.shortcuts import render
from django.urls import reverse, reverse_lazy from django.urls import reverse_lazy
from django.views import View from django.views import View
from django.views.generic.edit import CreateView from django.views.generic.edit import CreateView

View File

@ -33,4 +33,7 @@ forex_python
pyOpenSSL pyOpenSSL
Klein Klein
ConfigObject ConfigObject
aiohttp[speedups]
aioredis[hiredis]
elasticsearch[async]
uvloop