Implement adding bank links
This commit is contained in:
parent
3699fff272
commit
479e5b1022
22
app/urls.py
22
app/urls.py
|
@ -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)
|
||||||
|
|
|
@ -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,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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
||||||
|
from core.lib.schemas import nordigen_s # noqa
|
|
@ -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",
|
||||||
|
}
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -33,4 +33,7 @@ forex_python
|
||||||
pyOpenSSL
|
pyOpenSSL
|
||||||
Klein
|
Klein
|
||||||
ConfigObject
|
ConfigObject
|
||||||
|
aiohttp[speedups]
|
||||||
|
aioredis[hiredis]
|
||||||
|
elasticsearch[async]
|
||||||
|
uvloop
|
||||||
|
|
Loading…
Reference in New Issue