Browse Source
[IMP] *_online_ponto: more efficient ponto account retrieval
[IMP] *_online_ponto: more efficient ponto account retrieval
Also do not name variables account_id or account_ids when they do not refer to ids in the Odoo sense, nor to either bank or ledger accounts.12.0
Ronald Portier (Therp BV)
2 years ago
No known key found for this signature in database
GPG Key ID: A181F8124D7101D3
6 changed files with 360 additions and 211 deletions
-
8account_bank_statement_import_online_ponto/__manifest__.py
-
5account_bank_statement_import_online_ponto/models/__init__.py
-
261account_bank_statement_import_online_ponto/models/online_bank_statement_provider_ponto.py
-
79account_bank_statement_import_online_ponto/models/ponto_buffer.py
-
28account_bank_statement_import_online_ponto/models/ponto_buffer_line.py
-
190account_bank_statement_import_online_ponto/models/ponto_interface.py
@ -1,4 +1,5 @@ |
|||||
# Copyright 2020 Florent de Labarre |
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). |
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). |
||||
|
|
||||
|
from . import ponto_buffer |
||||
|
from . import ponto_buffer_line |
||||
|
from . import ponto_interface |
||||
from . import online_bank_statement_provider_ponto |
from . import online_bank_statement_provider_ponto |
@ -0,0 +1,79 @@ |
|||||
|
# Copyright 2022 Therp BV <https://therp.nl>. |
||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). |
||||
|
"""Define model to hold data retrieved from Ponto.""" |
||||
|
import json |
||||
|
import logging |
||||
|
|
||||
|
from odoo import _, fields, models |
||||
|
|
||||
|
_logger = logging.getLogger(__name__) |
||||
|
|
||||
|
|
||||
|
class PontoBuffer(models.Model): |
||||
|
"""Define model to hold data retrieved from Ponto.""" |
||||
|
_name = "ponto.buffer" |
||||
|
_description = "Group transactions retrieved from Ponto." |
||||
|
|
||||
|
provider_id = fields.Many2one( |
||||
|
comodel_name="online.bank.statement.provider", |
||||
|
required=True, |
||||
|
readonly=True, |
||||
|
) |
||||
|
effective_date = fields.Date(readonly=True, required=True) |
||||
|
buffer_line_ids = fields.One2many( |
||||
|
comodel_name="ponto.buffer.line", |
||||
|
inverse_name="buffer_id", |
||||
|
readonly=True, |
||||
|
ondelete="cascade", |
||||
|
) |
||||
|
|
||||
|
def _store_transactions(self, provider, transactions): |
||||
|
"""Store transactions retrieved from Ponto in buffer, preventing duplicates.""" |
||||
|
# Start by sorting all transactions per date. |
||||
|
transactions_per_date = {} |
||||
|
for transaction in transactions: |
||||
|
ponto_execution_date = transaction.get( |
||||
|
"attributes", {} |
||||
|
).get("executionDate") |
||||
|
effective_date_time = provider._ponto_date_from_string(ponto_execution_date) |
||||
|
transaction["effective_date_time"] = effective_date_time.isoformat() |
||||
|
key = effective_date_time.isoformat()[0:10] |
||||
|
if key not in transactions_per_date: |
||||
|
transactions_per_date[key] = [] # Initialize transaction array. |
||||
|
transactions_per_date[key].append(transaction) |
||||
|
# Now store the transactions, but not when already present. |
||||
|
for key, date_transactions in transactions_per_date.items(): |
||||
|
_logger.debug( |
||||
|
_("For date %s we retrieved %d transactions"), |
||||
|
key, |
||||
|
len(date_transactions) |
||||
|
) |
||||
|
ponto_buffer = self.search( |
||||
|
[ |
||||
|
("provider_id", "=", provider.id), |
||||
|
("effective_date", "=", key), |
||||
|
], |
||||
|
limit=1 |
||||
|
) or self.create( |
||||
|
{ |
||||
|
"provider_id": provider.id, |
||||
|
"effective_date": key, |
||||
|
} |
||||
|
) |
||||
|
already_present = ponto_buffer.buffer_line_ids.mapped("ponto_id") |
||||
|
new_lines = [ |
||||
|
( |
||||
|
0, |
||||
|
0, |
||||
|
{ |
||||
|
"buffer_id": ponto_buffer.id, |
||||
|
"ponto_id": t.get("id"), |
||||
|
"effective_date_time": t.get("effective_date_time"), |
||||
|
"transaction_data": json.dumps(t), |
||||
|
}, |
||||
|
) |
||||
|
for t in date_transactions |
||||
|
if t.get("id") not in already_present |
||||
|
] |
||||
|
if new_lines: |
||||
|
ponto_buffer.write({"buffer_line_ids": new_lines}) |
@ -0,0 +1,28 @@ |
|||||
|
# Copyright 2022 Therp BV <https://therp.nl>. |
||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). |
||||
|
"""Define model to hold transactions retrieved from Ponto.""" |
||||
|
from odoo import fields, models |
||||
|
|
||||
|
|
||||
|
class PontoBuffer(models.Model): |
||||
|
"""Define model to hold transactions retrieved from Ponto.""" |
||||
|
_name = "ponto.buffer.line" |
||||
|
_description = "Hold transactions retrieved from Ponto." |
||||
|
|
||||
|
buffer_id = fields.Many2one( |
||||
|
comodel_name="ponto.buffer", |
||||
|
required=True, |
||||
|
readonly=True, |
||||
|
) |
||||
|
ponto_id = fields.Char( |
||||
|
required=True, |
||||
|
readonly=True, |
||||
|
) |
||||
|
effective_date_time = fields.Datetime( |
||||
|
required=True, |
||||
|
readonly=True, |
||||
|
) |
||||
|
transaction_data = fields.Char( |
||||
|
required=True, |
||||
|
readonly=True, |
||||
|
) |
@ -0,0 +1,190 @@ |
|||||
|
# Copyright 2020 Florent de Labarre |
||||
|
# Copyright 2022 Therp BV <https://therp.nl>. |
||||
|
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). |
||||
|
|
||||
|
import base64 |
||||
|
import json |
||||
|
import logging |
||||
|
import time |
||||
|
|
||||
|
import requests |
||||
|
from dateutil.relativedelta import relativedelta |
||||
|
|
||||
|
from odoo import fields, models, _ |
||||
|
from odoo.exceptions import UserError |
||||
|
from odoo.addons.base.models.res_bank import sanitize_account_number |
||||
|
|
||||
|
_logger = logging.getLogger(__name__) |
||||
|
|
||||
|
PONTO_ENDPOINT = "https://api.myponto.com" |
||||
|
|
||||
|
|
||||
|
class PontoInterface(models.AbstractModel): |
||||
|
_name = "ponto.interface" |
||||
|
_description = "Interface to all interactions with Ponto API" |
||||
|
|
||||
|
def _login(self, username, password): |
||||
|
"""Ponto login returns an access dictionary for further requests.""" |
||||
|
url = PONTO_ENDPOINT + "/oauth2/token" |
||||
|
if not(username and password): |
||||
|
raise UserError(_("Please fill login and key.")) |
||||
|
login = "%s:%s" % (username, password) |
||||
|
login = base64.b64encode(login.encode("UTF-8")).decode("UTF-8") |
||||
|
login_headers = { |
||||
|
"Content-Type": "application/x-www-form-urlencoded", |
||||
|
"Accept": "application/json", |
||||
|
"Authorization": "Basic %s" % login, |
||||
|
} |
||||
|
response = requests.post( |
||||
|
url, |
||||
|
params={"grant_type": "client_credentials"}, |
||||
|
headers=login_headers, |
||||
|
) |
||||
|
data = self._get_response_data(response) |
||||
|
access_token = data.get("access_token", False) |
||||
|
if not access_token: |
||||
|
raise UserError(_("Ponto : no token")) |
||||
|
token_expiration = fields.Datetime.now() + relativedelta( |
||||
|
seconds=data.get("expires_in", False) |
||||
|
) |
||||
|
return { |
||||
|
"username": username, |
||||
|
"password": password, |
||||
|
"access_token": access_token, |
||||
|
"token_expiration": token_expiration, |
||||
|
} |
||||
|
|
||||
|
def _get_request_headers(self, access_data): |
||||
|
"""Get headers with authorization for further ponto requests.""" |
||||
|
if access_data["token_expiration"] <= fields.Datetime.now(): |
||||
|
updated_data = self._login(access_data["username"], access_data["password"]) |
||||
|
access_data.update(updated_data) |
||||
|
return { |
||||
|
"Accept": "application/json", |
||||
|
"Authorization": "Bearer %s" % access_data["access_token"], |
||||
|
} |
||||
|
|
||||
|
def _set_access_account(self, access_data, account_number): |
||||
|
"""Set ponto account for bank account in access_data.""" |
||||
|
url = PONTO_ENDPOINT + "/accounts" |
||||
|
response = requests.get( |
||||
|
url, params={"limit": 100}, headers=self._get_request_headers(access_data) |
||||
|
) |
||||
|
data = self._get_response_data(response) |
||||
|
for ponto_account in data.get("data", []): |
||||
|
ponto_iban = sanitize_account_number( |
||||
|
ponto_account.get("attributes", {}).get("reference", "") |
||||
|
) |
||||
|
if ponto_iban == account_number: |
||||
|
access_data["ponto_account"] = ponto_account.get("id") |
||||
|
return |
||||
|
# If we get here, we did not find Ponto account for bank account. |
||||
|
raise UserError( |
||||
|
_("Ponto : wrong configuration, account %s not found in %s") |
||||
|
% (account_number, data) |
||||
|
) |
||||
|
|
||||
|
def _ponto_synchronisation(self, access_data): |
||||
|
"""Make sure Ponto has retrieved latest data from financial institution.""" |
||||
|
url = PONTO_ENDPOINT + "/synchronizations" |
||||
|
# TODO: According to spec we should provide an IP number in the data. |
||||
|
# See: https://documentation.myponto.com/1/api/curl#create-synchronization |
||||
|
payload = { |
||||
|
"data": { |
||||
|
"type": "synchronization", |
||||
|
"attributes": { |
||||
|
"resourceType": "account", |
||||
|
"resourceId": access_data["ponto_account"], |
||||
|
"subtype": "accountTransactions", |
||||
|
}, |
||||
|
} |
||||
|
} |
||||
|
response = requests.post( |
||||
|
url, headers=self._get_request_headers(access_data), json=payload |
||||
|
) |
||||
|
if response.status_code == 400: |
||||
|
# Probably synchronization recently done already. |
||||
|
_logger.debug( |
||||
|
_("Syncronization request rejected: %s"), |
||||
|
response.text |
||||
|
) |
||||
|
return |
||||
|
data = self._get_response_data(response) |
||||
|
sync_id = data.get("attributes", {}).get("resourceId", False) |
||||
|
# Check synchronisation |
||||
|
if not sync_id: |
||||
|
return |
||||
|
# Poll synchronization during 400 seconds for completion. |
||||
|
url = PONTO_ENDPOINT + "/synchronizations/" + sync_id |
||||
|
number = 0 |
||||
|
while number <= 10: |
||||
|
number += 1 |
||||
|
response = requests.get(url, headers=self._get_request_headers(access_data)) |
||||
|
if response.status_code == 200: |
||||
|
data = json.loads(response.text) |
||||
|
status = data.get("status", {}) |
||||
|
if status in ("success", "error"): |
||||
|
if status == "error": |
||||
|
_logger.debug( |
||||
|
_("Syncronization was succesfully completed") |
||||
|
) |
||||
|
else: |
||||
|
_logger.debug( |
||||
|
_("Syncronization had an error: %s"), |
||||
|
response.text |
||||
|
) |
||||
|
return |
||||
|
time.sleep(40) |
||||
|
|
||||
|
def _get_transactions(self, access_data, last_identifier): |
||||
|
"""Get transactions from ponto, using last_identifier as pointer.""" |
||||
|
url = ( |
||||
|
PONTO_ENDPOINT |
||||
|
+ "/accounts/" |
||||
|
+ access_data["ponto_account"] |
||||
|
+ "/transactions" |
||||
|
) |
||||
|
params = {"limit": 100} |
||||
|
if last_identifier: |
||||
|
params["after"] = last_identifier |
||||
|
data = self._get_request(access_data, url, params) |
||||
|
transactions = self._get_transactions_from_data(data) |
||||
|
return transactions |
||||
|
|
||||
|
def _get_transactions_from_data(self, data): |
||||
|
"""Get all transactions that are in the ponto response data.""" |
||||
|
transactions = data.get("data", []) |
||||
|
if not transactions: |
||||
|
_logger.debug( |
||||
|
_("No transactions where found in data %s"), |
||||
|
data, |
||||
|
) |
||||
|
else: |
||||
|
_logger.debug( |
||||
|
_("%d transactions present in response data"), |
||||
|
len(transactions), |
||||
|
) |
||||
|
return transactions |
||||
|
|
||||
|
def _get_request(self, access_data, url, params): |
||||
|
"""Interact with Ponto to get next page of data.""" |
||||
|
headers = self._get_request_headers(access_data) |
||||
|
_logger.debug( |
||||
|
_("Get request to %s, with headers %s and params %s"), |
||||
|
url, |
||||
|
params, |
||||
|
headers |
||||
|
) |
||||
|
response = requests.get( |
||||
|
url, params=params, headers=headers |
||||
|
) |
||||
|
return self._get_response_data(response) |
||||
|
|
||||
|
def _get_response_data(self, response): |
||||
|
"""Get response data for GET or POST request.""" |
||||
|
if response.status_code not in (200, 201): |
||||
|
raise UserError( |
||||
|
_("Server returned status code %s: %s") |
||||
|
% (response.status_code, response.text) |
||||
|
) |
||||
|
return json.loads(response.text) |
Write
Preview
Loading…
Cancel
Save
Reference in new issue