Pingen: API v2 changes

This commit is contained in:
Anna Janiszewska
2023-01-30 12:56:55 +01:00
parent 0529340243
commit b72b28f004
14 changed files with 814 additions and 304 deletions

View File

@@ -1,5 +1,5 @@
# Author: Guewen Baconnier # Author: Guewen Baconnier
# Copyright 2012-2017 Camptocamp SA # Copyright 2012-2017 Camptocamp SA
# 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 controllers
from . import models from . import models

View File

@@ -4,7 +4,7 @@
{ {
"name": "pingen.com integration", "name": "pingen.com integration",
"version": "10.0.1.0.0", "version": "10.0.2.0.0",
"author": "Camptocamp,Odoo Community Association (OCA)", "author": "Camptocamp,Odoo Community Association (OCA)",
"maintainer": "Camptocamp", "maintainer": "Camptocamp",
"license": "AGPL-3", "license": "AGPL-3",
@@ -12,7 +12,7 @@
"complexity": "easy", "complexity": "easy",
"depends": ["base_setup"], "depends": ["base_setup"],
"external_dependencies": { "external_dependencies": {
"python": ["requests"], "python": ["requests_oauthlib"],
}, },
"website": "https://github.com/OCA/report-print-send", "website": "https://github.com/OCA/report-print-send",
"data": [ "data": [

View File

@@ -0,0 +1 @@
from . import main

141
pingen/controllers/main.py Normal file
View File

@@ -0,0 +1,141 @@
# Copyright 2022 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
import hashlib
import hmac
import json
import logging
import werkzeug
from odoo import http
from ..models.pingen import pingen_datetime_to_utc
_logger = logging.getLogger(__name__)
class PingenController(http.Controller):
def _verify_signature(self, request_content):
webhook_signature = http.request.httprequest.headers.get("Signature")
companies = (
http.request.env["res.company"]
.sudo()
.search([("pingen_webhook_secret", "!=", False)])
)
for company in companies:
secret_signature = hmac.new(
company.pingen_webhook_secret.encode("utf-8"),
request_content,
hashlib.sha256,
).hexdigest()
if webhook_signature == secret_signature:
return company
msg = "Webhook signature does not match with any company secret"
_logger.warning(msg)
raise werkzeug.exceptions.Forbidden()
def _get_request_content(self):
return http.request.httprequest.stream.read()
def _get_json_content(self, request_content):
return json.loads(request_content)
def _get_document_uuid(self, json_content):
return (
json_content.get("data", {})
.get("relationships", {})
.get("letter", {})
.get("data", {})
.get("id", "")
)
def _find_pingen_document(self, document_uuid):
if document_uuid:
return http.request.env["pingen.document"].search(
[("pingen_uuid", "=", document_uuid)]
)
return http.request.env["pingen.document"].browse()
def _get_error_reason(self, json_content):
return json_content.get("data", {}).get("attributes", {}).get("reason", "")
def _get_letter_infos(self, json_content, document_uuid):
for node in json_content.get("included", {}):
if node.get("type") == "letters" and node.get("id") == document_uuid:
return node.get("attributes", {})
return {}
def _get_emitted_date(self, json_content):
emitted_at = ""
for node in json_content.get("included", {}):
if node.get("type") == "letters_events":
attributes = node.get("attributes", {})
if attributes.get("code") == "transferred_to_distributor":
emitted_at = attributes.get("emitted_at", "")
break
return pingen_datetime_to_utc(emitted_at.encode())
def _update_pingen_document(self, request_content, values):
json_content = self._get_json_content(request_content)
document_uuid = self._get_document_uuid(json_content)
pingen_doc = self._find_pingen_document(document_uuid)
if pingen_doc:
info_values = pingen_doc._prepare_values_from_post_infos(
self._get_letter_infos(json_content, document_uuid)
)
info_values.update(values)
pingen_doc.sudo().write(info_values)
msg = "Pingen document with UUID %s updated successfully" % document_uuid
_logger.info(msg)
return msg
msg = "Could not find related Pingen document for UUID %s" % document_uuid
_logger.warning(msg)
return msg
@http.route(
"/pingen/letter_issues", type="http", auth="none", methods=["POST"], csrf=False
)
def letter_issues(self, **post):
_logger.info("Webhook call received on /pingen/letter_issues")
request_content = self._get_request_content()
json_content = self._get_json_content(request_content)
self._verify_signature(request_content)
values = {
"state": "pingen_error",
"last_error_message": self._get_error_reason(json_content),
}
self._update_pingen_document(request_content, values)
@http.route(
"/pingen/sent_letters", type="http", auth="none", methods=["POST"], csrf=False
)
def sent_letters(self, **post):
_logger.info("Webhook call received on /pingen/sent_letters")
request_content = self._get_request_content()
json_content = self._get_json_content(request_content)
self._verify_signature(request_content)
emitted_date = self._get_emitted_date(json_content)
values = {
"state": "sent",
}
if emitted_date:
values["send_date"] = emitted_date
self._update_pingen_document(request_content, values)
@http.route(
"/pingen/undeliverable_letters",
type="http",
auth="none",
methods=["POST"],
csrf=False,
)
def undeliverable_letters(self, **post):
_logger.info("Webhook call received on /pingen/undeliverable_letters")
request_content = self._get_request_content()
json_content = self._get_json_content(request_content)
self._verify_signature(request_content)
values = {
"state": "error_undeliverable",
"last_error_message": self._get_error_reason(json_content),
}
self._update_pingen_document(request_content, values)

View File

@@ -0,0 +1,195 @@
# Copyright 2022 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
from collections import namedtuple
from openupgradelib.openupgrade import (
add_fields,
column_exists,
logged_query,
migrate as oumigrate,
table_exists,
)
from psycopg2 import sql
field_definition = namedtuple("FieldDefinition", ["name", "type", "db_type"])
@oumigrate(use_env=True)
def migrate(env, version):
move_and_migrate_to_api_v2_field(
env,
"ir_attachment",
field_definition("pingen_speed", "selection", "integer"),
"id",
"pingen_document",
field_definition("delivery_product", "selection", "varchar"),
"attachment_id",
field_default="cheap",
values_mapping={"1": "fast", "2": "cheap"},
)
move_and_migrate_to_api_v2_field(
env,
"ir_attachment",
field_definition("pingen_color", "selection", "integer"),
"id",
"pingen_document",
field_definition("print_spectrum", "selection", "varchar"),
"attachment_id",
field_default="grayscale",
values_mapping={"0": "grayscale", "1": "color"},
)
move_and_migrate_to_api_v2_field(
env,
"ir_attachment",
field_definition("pingen_send", "boolean", "bool"),
"id",
"pingen_document",
field_definition("auto_send", "boolean", "bool"),
"attachment_id",
field_default="True",
)
migrate_to_api_v2_field(
env,
"pingen_document",
field_definition("pingen_id", "integer", "int4"),
field_definition("pingen_uuid", "char", "varchar"),
)
migrate_to_api_v2_field(
env,
"pingen_document",
field_definition("post_status", "char", "varchar"),
field_definition("pingen_status", "char", "varchar"),
)
def migrate_to_api_v2_field(
env, table_name, old_field_definition, new_field_definition
):
if table_exists(env.cr, table_name) and not column_exists(
env.cr, table_name, new_field_definition.name
):
add_fields(
env,
[
(
new_field_definition.name,
table_name.replace("_", "."),
table_name,
new_field_definition.type,
new_field_definition.db_type,
"pingen",
)
],
)
migrate_data(env.cr, table_name, old_field_definition, new_field_definition)
def move_and_migrate_to_api_v2_field(
env,
old_model_table,
old_field_definition,
old_model_id,
new_model_table,
new_field_definition,
new_model_id,
field_default=None,
values_mapping={},
):
if table_exists(env.cr, new_model_table) and not column_exists(
env.cr, new_model_table, new_field_definition.name
):
add_fields(
env,
[
(
new_field_definition.name,
new_model_table.replace("_", "."),
new_model_table,
new_field_definition.type,
new_field_definition.db_type,
"pingen",
)
],
)
move_and_migrate_data(
env.cr,
old_model_table,
old_field_definition,
new_model_table,
new_field_definition,
old_model_id,
new_model_id,
values_mapping=values_mapping,
)
def migrate_data(cr, table_name, old_field_definition, new_field_definition):
query = sql.SQL(
"""
UPDATE {table_name}
SET {new_fname} = {old_fname};
"""
)
format_params = {
"table_name": sql.Identifier(table_name),
"new_fname": sql.Identifier(new_field_definition.name),
"old_fname": sql.Identifier(old_field_definition.name),
}
if old_field_definition.db_type != new_field_definition.db_type:
format_params.update(
{
"old_fname": sql.SQL(
"%s::%s" % (old_field_definition.name, new_field_definition.db_type)
)
}
)
query = query.format(**format_params)
logged_query(cr, query)
def move_and_migrate_data(
cr,
old_table,
old_field_definition,
new_table,
new_field_definition,
old_table_key,
new_table_key,
values_mapping={},
):
query = sql.SQL(
"""
UPDATE {table_to}
SET {table_to_col} = {table_from_col}
FROM {table_from}
WHERE {table_to}.{table_to_id} = {table_from}.{table_from_id};"""
)
format_params = {
"table_to": sql.Identifier(new_table),
"table_from": sql.Identifier(old_table),
"table_to_col": sql.Identifier(new_field_definition.name),
"table_from_col": sql.Identifier(old_field_definition.name),
"table_to_id": sql.Identifier(new_table_key),
"table_from_id": sql.Identifier(old_table_key),
}
query = query.format(**format_params)
logged_query(cr, query)
for old_val, new_val in values_mapping.items():
values_query = sql.SQL(
"""
UPDATE {table_to}
SET {table_to_col} = {new_val}
WHERE {table_to_col} = {old_val};
"""
)
if new_val.isdigit():
format_params["new_val"] = sql.SQL(new_val)
else:
format_params["new_val"] = sql.Literal(new_val)
format_params.update({"old_val": sql.SQL(old_val)})
if old_field_definition.db_type != new_field_definition.db_type:
format_params.update(
{"old_val": sql.SQL("%s::%s" % (old_val, new_field_definition.db_type))}
)
values_query = values_query.format(**format_params)
logged_query(cr, values_query)

View File

@@ -6,5 +6,8 @@ from odoo import fields, models
class BaseConfigSettings(models.TransientModel): class BaseConfigSettings(models.TransientModel):
_inherit = "base.config.settings" _inherit = "base.config.settings"
pingen_token = fields.Char(related="company_id.pingen_token") pingen_clientid = fields.Char(related="company_id.pingen_clientid")
pingen_client_secretid = fields.Char(related="company_id.pingen_client_secretid")
pingen_organization = fields.Char(related="company_id.pingen_organization")
pingen_webhook_secret = fields.Char(related="company_id.pingen_webhook_secret")
pingen_staging = fields.Boolean(related="company_id.pingen_staging") pingen_staging = fields.Boolean(related="company_id.pingen_staging")

View File

@@ -18,23 +18,12 @@ class IrAttachment(models.Model):
pingen_document_ids = fields.One2many( pingen_document_ids = fields.One2many(
"pingen.document", "attachment_id", string="Pingen Document", readonly=True "pingen.document", "attachment_id", string="Pingen Document", readonly=True
) )
pingen_send = fields.Boolean(
"Send",
help="Defines if a document is merely uploaded or also sent",
default=True,
)
pingen_speed = fields.Selection(
[("1", "Priority"), ("2", "Economy")],
"Speed",
default="2",
help="Defines the sending speed if the document is automatically sent",
)
pingen_color = fields.Selection(
[("0", "B/W"), ("1", "Color")], "Type of print", default="0"
)
def _prepare_pingen_document_vals(self): def _prepare_pingen_document_vals(self):
return {"attachment_id": self.id, "config": "created from attachment"} return {
"attachment_id": self.id,
# 'config': 'created from attachment'
}
def _handle_pingen_document(self): def _handle_pingen_document(self):
"""Reponsible of the related ``pingen.document`` """Reponsible of the related ``pingen.document``

View File

@@ -9,35 +9,23 @@ from datetime import datetime
import pytz import pytz
import requests import requests
import urlparse import urlparse
from requests.packages.urllib3.filepost import encode_multipart_formdata from dateutil import parser
from oauthlib.oauth2 import BackendApplicationClient
from requests_oauthlib import OAuth2Session
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
POST_SENDING_STATUS = {
100: "Ready/Pending",
101: "Processing",
102: "Waiting for confirmation",
1: "Sent",
300: "Some error occured and object wasn't sent",
400: "Sending cancelled",
}
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" # this is the format used by pingen API
TZ = pytz.timezone("Europe/Zurich") # this is the timezone of the pingen API
def pingen_datetime_to_utc(dt): def pingen_datetime_to_utc(dt):
"""Convert a date/time used by pingen.com to UTC timezone """Convert a date/time used by pingen.com to UTC timezone
:param dt: pingen date/time as string (as received from the API) :param dt: pingen date/time iso string (as received from the API)
to convert to UTC to convert to UTC
:return: datetime in the UTC timezone :return: TZ naive datetime in the UTC timezone
""" """
utc = pytz.utc utc = pytz.utc
dt = datetime.strptime(dt, DATETIME_FORMAT) localized_dt = parser.parse(dt)
localized_dt = TZ.localize(dt, is_dst=True) return localized_dt.astimezone(utc).replace(tzinfo=None)
return localized_dt.astimezone(utc)
class PingenException(RuntimeError): class PingenException(RuntimeError):
@@ -52,28 +40,102 @@ class APIError(PingenException):
class Pingen(object): class Pingen(object):
"""Interface to the pingen.com API""" """Interface to the pingen.com API"""
def __init__(self, token, staging=True): def __init__(self, clientid, secretid, organization, staging=True):
self._token = token self.clientid = clientid
self.secretid = secretid
self.organization = organization
self.staging = staging self.staging = staging
self._session = None self._session = None
self._init_token_registry()
super(Pingen, self).__init__() super(Pingen, self).__init__()
@property @property
def url(self): def api_url(self):
if self.staging: if self.staging:
return "https://stage-api.pingen.com" return "https://api-staging.v2.pingen.com"
return "https://api.pingen.com" return "https://api.v2.pingen.com"
@property
def identity_url(self):
if self.staging:
return "https://identity-staging.pingen.com"
return "https://identity.pingen.com"
@property
def token_url(self):
return "auth/access-tokens"
@property
def file_upload_url(self):
return "file-upload"
@property @property
def session(self): def session(self):
"""Build a requests session""" """Build a requests session"""
if self._session is not None: if self._session is not None:
return self._session return self._session
self._session = requests.Session() client = BackendApplicationClient(client_id=self.clientid)
self._session.params = {"token": self._token} self._session = OAuth2Session(client=client)
self._session.verify = not self.staging self._set_session_header_token()
return self._session return self._session
@classmethod
def _init_token_registry(cls):
if hasattr(cls, "token_registry"):
return
cls.token_registry = {
"staging": {"token": "", "expiry": datetime.now()},
"prod": {"token": "", "expiry": datetime.now()},
}
@classmethod
def _get_token_infos(cls, staging):
if staging:
return cls.token_registry.get("staging")
else:
return cls.token_registry.get("prod")
@classmethod
def _set_token_data(cls, token_data, staging):
token_string = " ".join(
[token_data.get("token_type"), token_data.get("access_token")]
)
token_expiry = datetime.fromtimestamp(token_data.get("expires_at"))
if staging:
cls.token_registry["staging"] = {
"token": token_string,
"expiry": token_expiry,
}
else:
cls.token_registry["prod"] = {"token": token_string, "expiry": token_expiry}
def _fetch_token(self):
# TODO: Handle scope 'letter' only?
token_url = urlparse.urljoin(self.identity_url, self.token_url)
# FIXME: requests.exceptions.SSLError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed (_ssl.c:581)
# without verify=False parameter on prod/staging
_logger.debug("Fetching new token from %s" % token_url)
return self._session.fetch_token(
token_url=token_url,
client_id=self.clientid,
client_secret=self.secretid,
verify=False,
)
def _set_session_header_token(self):
if self._is_token_expired():
token_data = self._fetch_token()
self._set_token_data(token_data, self.staging)
token_infos = self._get_token_infos(self.staging)
self._session.headers["Authorization"] = token_infos.get("token")
def _is_token_expired(self):
token_infos = self._get_token_infos(self.staging)
expired = token_infos.get("expiry") <= datetime.now()
if expired:
_logger.debug("Pingen token is expired")
return expired
def __enter__(self): def __enter__(self):
return self return self
@@ -85,7 +147,7 @@ class Pingen(object):
if self._session: if self._session:
self._session.close() self._session.close()
def _send(self, method, endpoint, **kwargs): def _send(self, method, endpoint, letter_id="", **kwargs):
"""Send a request to the pingen API using requests """Send a request to the pingen API using requests
Add necessary boilerplate to call pingen.com API Add necessary boilerplate to call pingen.com API
@@ -96,110 +158,159 @@ class Pingen(object):
:param kwargs: additional arguments forwarded to the requests method :param kwargs: additional arguments forwarded to the requests method
""" """
p_url = urlparse.urljoin(self.url, endpoint) if self._is_token_expired():
self._set_session_header_token()
p_url = urlparse.urljoin(self.api_url, endpoint)
if endpoint == "document/get": if endpoint == "document/get":
complete_url = "{}{}{}{}{}".format( complete_url = "{}{}{}{}{}".format(
p_url, "/id/", kwargs["params"]["id"], "/token/", self._token p_url, "/id/", kwargs["params"]["id"], "/token/", self._token
) )
else: else:
complete_url = "{}{}{}".format(p_url, "/token/", self._token) complete_url = p_url.format(
organisationId=self.organization, letterId=letter_id
response = method(complete_url, **kwargs) )
response = method(complete_url, verify=False, **kwargs)
if response.json()["error"]: errors = response.json().get("errors")
raise APIError( if errors:
"%s: %s" raise APIError(
% (response.json()["errorcode"], response.json()["errormessage"]) "\n".join(
[
"%s (%s): %s"
% (err.get("code"), err.get("title"), err.get("detail"))
for err in errors
]
)
) )
return response return response
def push_document(self, filename, filestream, send=None, speed=None, color=None): def _get_file_upload(self):
_logger.debug("Getting new URL for file upload")
response = self._send(self.session.get, self.file_upload_url)
json_response_attributes = response.json().get("data", {}).get("attributes")
url = json_response_attributes.get("url")
url_signature = json_response_attributes.get("url_signature")
return url, url_signature
def upload_file(self, url, multipart, content_type):
_logger.debug("Uploading new file")
response = requests.put(
url, data=multipart, headers={"Content-Type": content_type}
)
return response
def push_document(
self,
filename,
filestream,
content_type,
send=None,
delivery_product=None,
print_spectrum=None,
print_mode=None,
):
"""Upload a document to pingen.com and eventually ask to send it """Upload a document to pingen.com and eventually ask to send it
:param str filename: name of the file to push :param str filename: name of the file to push
:param StringIO filestream: file to push :param StringIO filestream: file to push
:param boolean send: if True, the document will be sent by pingen.com :param boolean send: if True, the document will be sent by pingen.com
:param int/str speed: sending speed of the document if it is send :param str delivery_product: sending product of the document if it is send
1 = Priority, 2 = Economy :param str print_spectrum: type of print, grayscale or color
:param int/str color: type of print, 0 = B/W, 1 = Color
:return: tuple with 3 items: :return: tuple with 3 items:
1. document_id on pingen.com 1. document_id on pingen.com
2. post_id on pingen.com if it has been sent or None 2. post_id on pingen.com if it has been sent or None
3. dict of the created item on pingen (details) 3. dict of the created item on pingen (details)
""" """
data = {
"send": send,
"speed": speed,
"color": color,
}
# we cannot use the `files` param alongside # we cannot use the `files` param alongside
# with the `datas`param when data is a # with the `datas`param when data is a
# JSON-encoded data. We have to construct # JSON-encoded data. We have to construct
# the entire body and send it to `data` # the entire body and send it to `data`
# https://github.com/kennethreitz/requests/issues/950 # https://github.com/kennethreitz/requests/issues/950
formdata = { # formdata = {
"file": (filename, filestream.read()), # 'file': (filename, filestream.read()),
"data": json.dumps(data), # }
url, url_signature = self._get_file_upload()
# file_upload = self._get_file_upload()
# multipart, content_type = encode_multipart_formdata(formdata)
self.upload_file(url, filestream.read(), content_type)
data_attributes = {
"file_original_name": filename,
"file_url": url,
"file_url_signature": url_signature,
# TODO Use parameters and mapping
"address_position": "left",
"auto_send": send,
"delivery_product": delivery_product,
"print_spectrum": print_spectrum,
"print_mode": print_mode,
} }
multipart, content_type = encode_multipart_formdata(formdata) data = {"data": {"type": "letters", "attributes": data_attributes}}
response = self._send( response = self._send(
self.session.post, self.session.post,
"document/upload", "organisations/{organisationId}/letters",
headers={"Content-Type": content_type}, headers={"Content-Type": "application/vnd.api+json"},
data=multipart, data=json.dumps(data),
) )
rjson_data = response.json().get("data", {})
rjson = response.json() document_id = rjson_data.get("id")
# if rjson.get('send'):
# # confusing name but send_id is the posted id
# posted_id = rjson['send'][0]['send_id']
# item = rjson['item']
item = rjson_data.get("attributes")
document_id = rjson["id"] return document_id, False, item
if rjson.get("send"):
# confusing name but send_id is the posted id
posted_id = rjson["send"][0]["send_id"]
item = rjson["item"]
return document_id, posted_id, item def send_document(
self, document_uuid, delivery_product=None, print_spectrum=None, print_mode=None
def send_document(self, document_id, speed=None, color=None): ):
"""Send a uploaded document to pingen.com """Send a uploaded document to pingen.com
:param int document_id: id of the document to send :param str document_uuid: id of the document to send
:param int/str speed: sending speed of the document if it is send :param str delivery_product: sending product of the document
1 = Priority, 2 = Economy :param str print_spectrum: type of print, grayscale or color
:param int/str color: type of print, 0 = B/W, 1 = Color
:return: id of the post on pingen.com :return: id of the post on pingen.com
""" """
data_attributes = {
"delivery_product": delivery_product,
"print_mode": print_mode,
"print_spectrum": print_spectrum,
}
data = { data = {
"speed": speed, "data": {
"color": color, "id": document_uuid,
"type": "letters",
"attributes": data_attributes,
}
} }
response = self._send( response = self._send(
self.session.post, self.session.patch,
"document/send", "organisations/{organisationId}/letters/{letterId}/send",
params={"id": document_id}, letter_id=document_uuid,
data={"data": json.dumps(data)}, headers={"Content-Type": "application/vnd.api+json"},
data=json.dumps(data),
) )
return response.json().get("data", {}).get("attributes")
return response.json()["id"] def post_infos(self, document_uuid):
def post_infos(self, post_id):
"""Return the information of a post """Return the information of a post
:param int post_id: id of the document to send :param str document_uuid: id of the document to send
:return: dict of infos of the post :return: dict of infos of the post
""" """
response = self._send(self.session.get, "document/get", params={"id": post_id}) response = self._send(
self.session.get,
return response.json()["item"] "organisations/{organisationId}/letters/{letterId}",
letter_id=document_uuid,
@staticmethod )
def is_posted(post_infos): return response.json().get("data", {}).get("attributes")
"""return True if the post has been sent # return response.json()['item']
:param dict post_infos: post infos returned by `post_infos`
"""
return post_infos["status"] == 1

View File

@@ -3,15 +3,16 @@
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging import logging
from itertools import groupby
from cStringIO import StringIO from cStringIO import StringIO
from requests.exceptions import ConnectionError from oauthlib.oauth2.rfc6749.errors import OAuth2Error
import odoo import odoo
from odoo import _, fields, models from odoo import _, api, fields, models
from odoo.exceptions import UserError from odoo.exceptions import UserError
from .pingen import POST_SENDING_STATUS, APIError, pingen_datetime_to_utc from .pingen import APIError, pingen_datetime_to_utc
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -26,6 +27,7 @@ class PingenDocument(models.Model):
_name = "pingen.document" _name = "pingen.document"
_inherits = {"ir.attachment": "attachment_id"} _inherits = {"ir.attachment": "attachment_id"}
_order = "push_date desc, id desc"
attachment_id = fields.Many2one( attachment_id = fields.Many2one(
"ir.attachment", "Document", required=True, readonly=True, ondelete="cascade" "ir.attachment", "Document", required=True, readonly=True, ondelete="cascade"
@@ -36,6 +38,7 @@ class PingenDocument(models.Model):
("pushed", "Pushed"), ("pushed", "Pushed"),
("sendcenter", "In Sendcenter"), ("sendcenter", "In Sendcenter"),
("sent", "Sent"), ("sent", "Sent"),
("error_undeliverable", "Undeliverable"),
("error", "Connection Error"), ("error", "Connection Error"),
("pingen_error", "Pingen Error"), ("pingen_error", "Pingen Error"),
("canceled", "Canceled"), ("canceled", "Canceled"),
@@ -45,26 +48,58 @@ class PingenDocument(models.Model):
required=True, required=True,
default="pending", default="pending",
) )
auto_send = fields.Boolean(
"Auto Send",
help="Defines if a document is merely uploaded or also sent",
default=True,
)
delivery_product = fields.Selection(
[
("fast", "fast"),
("cheap", "cheap"),
("bulk", "bulk"),
("track", "track"),
("sign", "sign"),
("atpost_economy", "atpost_economy"),
("atpost_priority", "atpost_priority"),
("postag_a", "postag_a"),
("postag_b", "postag_b"),
("postag_b2", "postag_b2"),
("postag_registered", "postag_registered"),
("postag_aplus", "postag_aplus"),
("dpag_standard", "dpag_standard"),
("dpag_economy", "dpag_economy"),
("indpost_mail", "indpost_mail"),
("indpost_speedmail", "indpost_speedmail"),
("nlpost_priority", "nlpost_priority"),
("dhl_priority", "dhl_priority"),
],
"Delivery product",
default="cheap",
)
print_spectrum = fields.Selection(
[("grayscale", "Grayscale"), ("color", "Color")],
"Print Spectrum",
default="grayscale",
)
print_mode = fields.Selection(
[("simplex", "Simplex"), ("duplex", "Duplex")], "Print mode", default="simplex"
)
push_date = fields.Datetime("Push Date", readonly=True) push_date = fields.Datetime("Push Date", readonly=True)
# for `error` and `pingen_error` states when we push # for `error` and `pingen_error` states when we push
last_error_message = fields.Text("Error Message", readonly=True) last_error_message = fields.Text("Error Message", readonly=True)
# pingen IDs # pingen API v2 fields
pingen_id = fields.Integer( pingen_uuid = fields.Char(readonly=True)
"Pingen ID", readonly=True, help="ID of the document in the Pingen Documents" pingen_status = fields.Char(readonly=True)
)
post_id = fields.Integer(
"Pingen Post ID",
readonly=True,
help="ID of the document in the Pingen Sendcenter",
)
# sendcenter infos # sendcenter infos
post_status = fields.Char("Post Status", size=128, readonly=True)
parsed_address = fields.Text("Parsed Address", readonly=True) parsed_address = fields.Text("Parsed Address", readonly=True)
cost = fields.Float("Cost", readonly=True) cost = fields.Float("Cost", readonly=True)
currency_id = fields.Many2one("res.currency", "Currency", readonly=True) currency_id = fields.Many2one("res.currency", "Currency", readonly=True)
country_id = fields.Many2one("res.country", "Country", readonly=True) country_id = fields.Many2one("res.country", "Country", readonly=True)
send_date = fields.Datetime("Date of sending", readonly=True) send_date = fields.Datetime("Date of sending", readonly=True)
pages = fields.Integer("Pages", readonly=True) pages = fields.Integer("Pages", readonly=True)
company_id = fields.Many2one(related="attachment_id.company_id")
_sql_constraints = [ _sql_constraints = [
( (
@@ -74,55 +109,53 @@ class PingenDocument(models.Model):
), ),
] ]
def _get_pingen_session(self):
"""Returns a pingen session for a user"""
return self.company_id._pingen()
def _push_to_pingen(self, pingen=None): def _push_to_pingen(self, pingen=None):
"""Push a document to pingen.com """Push a document to pingen.com
:param Pingen pingen: optional pingen object to reuse session :param Pingen pingen: optional pingen object to reuse session
""" """
decoded_document = self.attachment_id._decoded_content() decoded_document = self.attachment_id._decoded_content()
if pingen is None: if pingen is None:
pingen = self._get_pingen_session() pingen = self.company_id._get_pingen_client()
try: try:
doc_id, post_id, infos = pingen.push_document( doc_id, post_id, infos = pingen.push_document(
self.datas_fname, self.name,
StringIO(decoded_document), StringIO(decoded_document),
self.pingen_send, self.attachment_id.mimetype,
self.pingen_speed, self.auto_send,
self.pingen_color, self.delivery_product,
self.print_spectrum,
self.print_mode,
) )
except ConnectionError: except OAuth2Error as e:
_logger.exception( _logger.exception(
"Connection Error when pushing Pingen Document %s to %s." "Connection Error when pushing Pingen Document with ID %s to %s: %s"
% (self.id, pingen.url) % (self.id, pingen.api_url, e.description)
) )
raise raise
except APIError: except APIError:
_logger.error( _logger.error(
"API Error when pushing Pingen Document %s to %s." "API Error when pushing Pingen Document %s to %s."
% (self.id, pingen.url) % (self.id, pingen.api_url)
) )
raise raise
error = False error = False
state = "pushed" state = "pushed"
if post_id: # if post_id:
state = "sendcenter" # state = 'sendcenter'
elif infos["requirement_failure"]: # elif infos['requirement_failure']:
state = "pingen_error" # state = 'pingen_error'
error = _("The document does not meet the Pingen requirements.") # error = _('The document does not meet the Pingen requirements.')
push_date = pingen_datetime_to_utc(infos["date"]) push_date = pingen_datetime_to_utc(infos.get("created_at"))
self.write( self.write(
{ {
"last_error_message": error, "last_error_message": error,
"state": state, "state": state,
"push_date": fields.Datetime.to_string(push_date), "push_date": fields.Datetime.to_string(push_date),
"pingen_id": doc_id, "pingen_uuid": doc_id,
"post_id": post_id, "pingen_status": infos.get("status"),
}, }
) )
_logger.info("Pingen Document %s: pushed to %s" % (self.id, pingen.url)) _logger.info("Pingen Document %s: pushed to %s" % (self.id, pingen.api_url))
def push_to_pingen(self): def push_to_pingen(self):
"""Push a document to pingen.com """Push a document to pingen.com
@@ -134,31 +167,24 @@ class PingenDocument(models.Model):
state = False state = False
error_msg = False error_msg = False
try: try:
session = self._get_pingen_session() session = self.company_id._get_pingen_client()
self._push_to_pingen(pingen=session) self._push_to_pingen(pingen=session)
except ConnectionError: except OAuth2Error:
state = "error" state = "error"
error_msg = ( error_msg = (
_( _("Connection Error when pushing document %s to Pingen") % self.name
"Connection Error when asking for "
"sending the document %s to Pingen"
)
% self.name
) )
except APIError as e: except APIError as e:
state = "pingen_error" state = "pingen_error"
error_msg = _("Error when pushing the document %s to Pingen:\n%s") % (
self.name,
e,
)
except Exception as e:
error_msg = _( error_msg = _(
"Error when asking Pingen to send the document %s: " "\n%s" "Unexpected Error when pushing the document %s to Pingen:\n%s"
) % (self.name, e) ) % (self.name, e)
except Exception: _logger.exception(error_msg)
_logger.exception(
"Unexpected Error when updating the status of pingen.document "
"%s: " % self.id
)
error_msg = (
_("Unexpected Error when updating the " "status of Document %s")
% self.name
)
finally: finally:
if error_msg: if error_msg:
vals = {"last_error_message": error_msg} vals = {"last_error_message": error_msg}
@@ -184,32 +210,37 @@ class PingenDocument(models.Model):
new_env = odoo.api.Environment(new_cr, self.env.uid, self.env.context) new_env = odoo.api.Environment(new_cr, self.env.uid, self.env.context)
# Instead of raising, store the error in the pingen.document # Instead of raising, store the error in the pingen.document
self = self.with_env(new_env) self = self.with_env(new_env)
not_sent_docs = self.search([("state", "!=", "sent")]) not_sent_docs = self.search(
for document in not_sent_docs: [("state", "!=", "sent")], order="company_id"
session = document._get_pingen_session() )
if document.state == "error": for company, documents in groupby(
document._resolve_error() not_sent_docs, lambda d: d.company_id
document.refresh() ):
try: session = company._get_pingen_client()
if document.state == "pending": for document in documents:
document._push_to_pingen(pingen=session) if document.state == "error":
elif document.state == "pushed": document._resolve_error()
document._ask_pingen_send(pingen=session) document.refresh()
except ConnectionError as e: try:
document.write({"last_error_message": e, "state": "error"}) if document.state == "pending":
except APIError as e: document._push_to_pingen(pingen=session)
document.write( elif document.state == "pushed" and not document.auto_send:
{"last_error_message": e, "state": "pingen_error"} document._ask_pingen_send(pingen=session)
) except OAuth2Error as e:
except BaseException: document.write({"last_error_message": e, "state": "error"})
_logger.error("Unexpected error in pingen cron") except APIError as e:
document.write(
{"last_error_message": e, "state": "pingen_error"}
)
except BaseException:
_logger.error("Unexpected error in pingen cron")
return True return True
def _resolve_error(self): def _resolve_error(self):
"""A document as resolved, put in the correct state""" """A document as resolved, put in the correct state"""
if self.post_id: if self.send_date:
state = "sendcenter" state = "sent"
elif self.pingen_id: elif self.pingen_uuid:
state = "pushed" state = "pushed"
else: else:
state = "pending" state = "pending"
@@ -225,32 +256,34 @@ class PingenDocument(models.Model):
"""For a document already pushed to pingen, ask to send it. """For a document already pushed to pingen, ask to send it.
:param Pingen pingen: pingen object to reuse :param Pingen pingen: pingen object to reuse
""" """
# sending has been explicitely asked so we change the option
# for consistency
if not self.pingen_send:
self.write({"pingen_send": True})
try: try:
post_id = pingen.send_document( infos = pingen.send_document(
self.pingen_id, self.pingen_speed, self.pingen_color self.pingen_uuid,
self.delivery_product,
self.print_spectrum,
self.print_mode,
) )
except ConnectionError: except OAuth2Error:
_logger.exception( _logger.exception(
"Connection Error when asking for sending Pingen Document %s " "Connection Error when asking for sending Pingen Document %s "
"to %s." % (self.id, pingen.url) "to %s." % (self.id, pingen.api_url)
) )
raise raise
except APIError: except APIError:
_logger.exception( _logger.exception(
"API Error when asking for sending Pingen Document %s to %s." "API Error when asking for sending Pingen Document %s to %s."
% (self.id, pingen.url) % (self.id, pingen.api_url)
) )
raise raise
self.write( self.write(
{"last_error_message": False, "state": "sendcenter", "post_id": post_id} {
"last_error_message": False,
"state": "sendcenter",
"pingen_status": infos.get("status"),
}
) )
_logger.info( _logger.info(
"Pingen Document %s: asked for sending to %s" % (self.id, pingen.url) "Pingen Document %s: asked for sending to %s" % (self.id, pingen.api_url)
) )
return True return True
@@ -261,9 +294,9 @@ class PingenDocument(models.Model):
""" """
self.ensure_one() self.ensure_one()
try: try:
session = self._get_pingen_session() session = self.company_id._get_pingen_client()
self._ask_pingen_send(pingen=session) self._ask_pingen_send(pingen=session)
except ConnectionError: except OAuth2Error:
raise UserError( raise UserError(
_( _(
"Connection Error when asking for " "Connection Error when asking for "
@@ -289,40 +322,64 @@ class PingenDocument(models.Model):
) )
return True return True
def _update_post_infos(self, pingen): def _get_and_update_post_infos(self, pingen):
"""Update the informations from """Update the informations from
pingen of a document in the Sendcenter pingen of a document in the Sendcenter
:param Pingen pingen: pingen object to reuse :param Pingen pingen: pingen object to reuse
""" """
if not self.pingen_id: post_infos = self._get_post_infos(pingen)
self._update_post_infos(post_infos)
def _get_post_infos(self, pingen):
if not self.pingen_uuid:
return return
try: try:
post_infos = pingen.post_infos(self.pingen_id) post_infos = pingen.post_infos(self.pingen_uuid)
except ConnectionError: except OAuth2Error:
_logger.exception( _logger.exception(
"Connection Error when asking for " "Connection Error when asking for "
"sending Pingen Document %s to %s." % (self.id, pingen.url) "sending Pingen Document %s to %s." % (self.id, pingen.api_url)
) )
raise raise
except APIError: except APIError:
_logger.exception( _logger.exception(
"API Error when asking for sending Pingen Document %s to %s." "API Error when asking for sending Pingen Document %s to %s."
% (self.id, pingen.url) % (self.id, pingen.api_url)
) )
raise raise
country = self.env["res.country"].search([("code", "=", post_infos["country"])]) return post_infos
send_date = pingen_datetime_to_utc(post_infos["date"])
@api.model
def _prepare_values_from_post_infos(self, post_infos):
country = self.env["res.country"].search(
[("code", "=", post_infos.get("country"))]
)
currency = self.env["res.currency"].search(
[("name", "=", post_infos.get("price_currency"))]
)
vals = { vals = {
"post_status": POST_SENDING_STATUS[post_infos["status"]], "pingen_status": post_infos.get("status"),
"parsed_address": post_infos["address"], "parsed_address": post_infos.get("address"),
"country_id": country.id, "country_id": country.id,
"send_date": fields.Datetime.to_string(send_date), "pages": post_infos.get("file_pages"),
"pages": post_infos["pages"],
"last_error_message": False, "last_error_message": False,
"cost": post_infos.get("price_value"),
"currency_id": currency.id,
} }
if pingen.is_posted(post_infos): is_posted = post_infos.get("status") == "sent"
if is_posted:
post_date = post_infos.get("submitted_at")
send_date = fields.Datetime.to_string(pingen_datetime_to_utc(post_date))
vals["state"] = "sent" vals["state"] = "sent"
self.write(vals) else:
send_date = False
vals["send_date"] = send_date
return vals
def _update_post_infos(self, post_infos):
self.ensure_one()
values = self._prepare_values_from_post_infos(post_infos)
self.write(values)
_logger.info("Pingen Document %s: status updated" % self.id) _logger.info("Pingen Document %s: status updated" % self.id)
def _update_post_infos_cron(self): def _update_post_infos_cron(self):
@@ -338,10 +395,10 @@ class PingenDocument(models.Model):
self = self.with_env(new_env) self = self.with_env(new_env)
pushed_docs = self.search([("state", "!=", "sent")]) pushed_docs = self.search([("state", "!=", "sent")])
for document in pushed_docs: for document in pushed_docs:
session = document._get_pingen_session() session = document.company_id._get_pingen_client()
try: try:
document._update_post_infos(pingen=session) document._get_and_update_post_infos(pingen=session)
except (ConnectionError, APIError): except (OAuth2Error, APIError):
# will be retried the next time # will be retried the next time
# In any case, the error has been # In any case, the error has been
# logged by _update_post_infos # logged by _update_post_infos
@@ -358,9 +415,9 @@ class PingenDocument(models.Model):
""" """
self.ensure_one() self.ensure_one()
try: try:
session = self._get_pingen_session() session = self.company_id._get_pingen_client()
self._update_post_infos(pingen=session) self._get_and_update_post_infos(pingen=session)
except ConnectionError: except OAuth2Error:
raise UserError( raise UserError(
_( _(
"Connection Error when updating the status " "Connection Error when updating the status "

View File

@@ -11,10 +11,25 @@ class ResCompany(models.Model):
_inherit = "res.company" _inherit = "res.company"
pingen_token = fields.Char("Pingen Token", size=32) pingen_clientid = fields.Char("Pingen Client ID", size=20)
pingen_client_secretid = fields.Char("Pingen Client Secret ID", size=80)
pingen_organization = fields.Char("Pingen organization ID")
pingen_webhook_secret = fields.Char("Pingen Webhooks secret")
pingen_staging = fields.Boolean("Pingen Staging") pingen_staging = fields.Boolean("Pingen Staging")
def _pingen(self): def _pingen(self):
"""Return a Pingen instance to work on""" """Return a Pingen instance to work on"""
self.ensure_one() self.ensure_one()
return Pingen(self.pingen_token, staging=self.pingen_staging)
clientid = self.pingen_clientid
secretid = self.pingen_client_secretid
return Pingen(
clientid,
secretid,
organization=self.pingen_organization,
staging=self.pingen_staging,
)
def _get_pingen_client(self):
"""Returns a pingen session for a user"""
return self._pingen()

View File

@@ -7,8 +7,11 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<group name="report" position="before"> <group name="report" position="before">
<group string="Pingen Integration"> <group string="Pingen Integration">
<field name="pingen_token" groups="base.group_system" /> <field name="pingen_clientid" groups="base.group_system" />
<field name="pingen_staging" groups="base.group_system" /> <field name="pingen_client_secretid" groups="base.group_system" />
<field name="pingen_organization" groups="base.group_system" />
<field name="pingen_webhook_secret" groups="base.group_system" />
<field name="pingen_staging" groups="base.group_system" />
</group> </group>
</group> </group>
</field> </field>

View File

@@ -9,15 +9,6 @@
<xpath expr="//group/group[2]" position="after"> <xpath expr="//group/group[2]" position="after">
<group string="Pingen info" groups="base.group_no_one"> <group string="Pingen info" groups="base.group_no_one">
<field name="send_to_pingen" /> <field name="send_to_pingen" />
<field
name="pingen_send"
attrs="{'required': [('send_to_pingen', '=', True)]}"
/>
<field
name="pingen_speed"
attrs="{'required': [('pingen_send', '=', True)]}"
/>
<field name="pingen_color" />
</group> </group>
</xpath> </xpath>
</field> </field>

View File

@@ -7,12 +7,13 @@
<field name="type">tree</field> <field name="type">tree</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<tree string="Pingen Document"> <tree string="Pingen Document">
<field name="push_date" />
<field name="name" /> <field name="name" />
<field name="datas_fname" /> <field name="auto_send" />
<field name="pingen_send" /> <field name="delivery_product" />
<field name="pingen_speed" /> <field name="print_spectrum" />
<field name="pingen_color" />
<field name="state" /> <field name="state" />
<field name="pingen_status" />
</tree> </tree>
</field> </field>
</record> </record>
@@ -24,6 +25,34 @@
<field name="arch" type="xml"> <field name="arch" type="xml">
<form string="Pingen Document"> <form string="Pingen Document">
<header> <header>
<button
name="push_to_pingen"
type="object"
states="pending"
string="Push to pingen.com"
icon="fa-upload"
/>
<button
name="ask_pingen_send"
type="object"
string="Ask pingen.com to send the document"
icon="fa-share"
attrs="{'invisible': ['|', ('state', '!=', 'pushed'),('auto_send', '=', True)]}"
/>
<button
name="resolve_error"
type="object"
states="error,pingen_error"
string="Errors resolved"
icon="fa-repeat"
/>
<button
name="update_post_infos"
type="object"
states="pushed,sendcenter,sent"
string="Update the letter's informations"
icon="fa-refresh"
/>
<field <field
name="state" name="state"
widget="statusbar" widget="statusbar"
@@ -31,7 +60,7 @@
statusbar_colors='{"error":"red","pingen_error":"red","canceled":"grey","pushed":"blue","sent":"green"}' statusbar_colors='{"error":"red","pingen_error":"red","canceled":"grey","pushed":"blue","sent":"green"}'
/> />
</header> </header>
<group colspan="4" col="6"> <group>
<field name="name" readonly="True" /> <field name="name" readonly="True" />
<field name="type" readonly="True" /> <field name="type" readonly="True" />
<field <field
@@ -41,54 +70,62 @@
widget="selection" widget="selection"
/> />
</group> </group>
<notebook colspan="4"> <notebook>
<page string="Pingen.com"> <page string="Pingen.com">
<separator string="Options" colspan="4" /> <group>
<newline /> <group>
<group col="2" colspan="2"> <field name="pingen_uuid" />
<field <field name="pingen_status" />
name="pingen_send" </group>
attrs="{'readonly': [('state', 'in', ['sendcenter', 'sent'])]}"
/>
<field
name="pingen_speed"
attrs="{'readonly': [('state', 'in', ['sendcenter', 'sent'])], 'required': [('pingen_send', '=', True)]}"
/>
<field
name="pingen_color"
attrs="{'readonly': [('state', 'in', ['sendcenter', 'sent'])]}"
/>
</group> </group>
<separator string="Options" />
<separator string="Dates" colspan="4" />
<newline /> <newline />
<group col="2" colspan="2"> <group>
<group>
<field
name="auto_send"
attrs="{'readonly': [('state', 'in', ['sendcenter', 'sent'])]}"
/>
<field
name="delivery_product"
attrs="{'readonly': [('state', 'in', ['sendcenter', 'sent'])], 'required': [('auto_send', '=', True)]}"
/>
<field
name="print_spectrum"
attrs="{'readonly': [('state', 'in', ['sendcenter', 'sent'])]}"
/>
<field
name="print_mode"
attrs="{'readonly': [('state', 'in', ['sendcenter', 'sent'])]}"
/>
</group>
</group>
<separator string="Dates" />
<newline />
<group>
<field name="push_date" /> <field name="push_date" />
</group> </group>
<group attrs="{'invisible': [('last_error_message', '=', False)]}">
<group <separator string="Errors" />
colspan="4"
attrs="{'invisible': [('last_error_message', '=', False)]}"
>
<separator string="Errors" colspan="4" />
<newline /> <newline />
<group col="2" colspan="2"> <group>
<field nolabel="1" name="last_error_message" /> <field nolabel="1" name="last_error_message" />
</group> </group>
</group> </group>
<group <group attrs="{'invisible': [('state', 'not in', ['sendcenter', 'sent'])]}">
colspan="4"
attrs="{'invisible': [('state', 'not in', ['sendcenter', 'sent'])]}"
>
<separator string="Sendcenter" colspan="4" /> <separator string="Sendcenter" colspan="4" />
<newline /> <newline />
<group col="4" colspan="2"> <group>
<field colspan="4" name="post_status" /> <label for="cost" />
<group col="3" colspan="2"> <div>
<field name="cost" /> <field name="cost" class="oe_inline" />
<field colspan="1" nolabel="1" name="currency_id" /> <field
</group> name="currency_id"
class="oe_inline"
groups="base.group_multi_currency"
/>
</div>
<newline /> <newline />
<field name="parsed_address" /> <field name="parsed_address" />
<field name="country_id" /> <field name="country_id" />
@@ -96,39 +133,6 @@
<field name="pages" /> <field name="pages" />
</group> </group>
</group> </group>
<separator string="Actions" colspan="4" />
<newline />
<group col="2" colspan="2">
<button
name="push_to_pingen"
type="object"
states="pending"
string="Push to pingen.com"
icon="fa-upload"
/>
<button
name="ask_pingen_send"
type="object"
states="pushed"
string="Ask pingen.com to send the document"
icon="fa-share"
/>
<button
name="resolve_error"
type="object"
states="error,pingen_error"
string="Errors resolved"
icon="fa-repeat"
/>
<button
name="update_post_infos"
type="object"
states="sendcenter"
string="Update the letter's informations"
icon="fa-refresh"
/>
</group>
</page> </page>
<page string="Attachment"> <page string="Attachment">
<group col="4" colspan="4"> <group col="4" colspan="4">

View File

@@ -1,3 +1,3 @@
# generated from manifests external_dependencies # generated from manifests external_dependencies
pycups pycups
requests requests_oauthlib