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
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). |
|||
|
|||
from . import ponto_buffer |
|||
from . import ponto_buffer_line |
|||
from . import ponto_interface |
|||
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