diff --git a/pingen/__init__.py b/pingen/__init__.py index b90e099..97f8526 100644 --- a/pingen/__init__.py +++ b/pingen/__init__.py @@ -1,5 +1,5 @@ # Author: Guewen Baconnier # Copyright 2012-2017 Camptocamp SA # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). - +from . import controllers from . import models diff --git a/pingen/__manifest__.py b/pingen/__manifest__.py index 1cb10e3..a311549 100644 --- a/pingen/__manifest__.py +++ b/pingen/__manifest__.py @@ -4,7 +4,7 @@ { "name": "pingen.com integration", - "version": "10.0.1.0.0", + "version": "10.0.2.0.0", "author": "Camptocamp,Odoo Community Association (OCA)", "maintainer": "Camptocamp", "license": "AGPL-3", @@ -12,7 +12,7 @@ "complexity": "easy", "depends": ["base_setup"], "external_dependencies": { - "python": ["requests"], + "python": ["requests_oauthlib"], }, "website": "https://github.com/OCA/report-print-send", "data": [ diff --git a/pingen/controllers/__init__.py b/pingen/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/pingen/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/pingen/controllers/main.py b/pingen/controllers/main.py new file mode 100644 index 0000000..ec1c73d --- /dev/null +++ b/pingen/controllers/main.py @@ -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) diff --git a/pingen/migrations/10.0.2.0.0/pre-migration.py b/pingen/migrations/10.0.2.0.0/pre-migration.py new file mode 100644 index 0000000..28fc36b --- /dev/null +++ b/pingen/migrations/10.0.2.0.0/pre-migration.py @@ -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) diff --git a/pingen/models/base_config_settings.py b/pingen/models/base_config_settings.py index 2b1b151..e0ad342 100644 --- a/pingen/models/base_config_settings.py +++ b/pingen/models/base_config_settings.py @@ -6,5 +6,8 @@ from odoo import fields, models class BaseConfigSettings(models.TransientModel): _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") diff --git a/pingen/models/ir_attachment.py b/pingen/models/ir_attachment.py index f88169a..90ec86e 100644 --- a/pingen/models/ir_attachment.py +++ b/pingen/models/ir_attachment.py @@ -18,23 +18,12 @@ class IrAttachment(models.Model): pingen_document_ids = fields.One2many( "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): - return {"attachment_id": self.id, "config": "created from attachment"} + return { + "attachment_id": self.id, + # 'config': 'created from attachment' + } def _handle_pingen_document(self): """Reponsible of the related ``pingen.document`` diff --git a/pingen/models/pingen.py b/pingen/models/pingen.py index 2ce8dfa..6f190d3 100644 --- a/pingen/models/pingen.py +++ b/pingen/models/pingen.py @@ -9,35 +9,23 @@ from datetime import datetime import pytz import requests 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__) -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): """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 - :return: datetime in the UTC timezone + :return: TZ naive datetime in the UTC timezone """ utc = pytz.utc - dt = datetime.strptime(dt, DATETIME_FORMAT) - localized_dt = TZ.localize(dt, is_dst=True) - return localized_dt.astimezone(utc) + localized_dt = parser.parse(dt) + return localized_dt.astimezone(utc).replace(tzinfo=None) class PingenException(RuntimeError): @@ -52,28 +40,102 @@ class APIError(PingenException): class Pingen(object): """Interface to the pingen.com API""" - def __init__(self, token, staging=True): - self._token = token + def __init__(self, clientid, secretid, organization, staging=True): + self.clientid = clientid + self.secretid = secretid + self.organization = organization self.staging = staging self._session = None + self._init_token_registry() super(Pingen, self).__init__() @property - def url(self): + def api_url(self): if self.staging: - return "https://stage-api.pingen.com" - return "https://api.pingen.com" + return "https://api-staging.v2.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 def session(self): """Build a requests session""" if self._session is not None: return self._session - self._session = requests.Session() - self._session.params = {"token": self._token} - self._session.verify = not self.staging + client = BackendApplicationClient(client_id=self.clientid) + self._session = OAuth2Session(client=client) + self._set_session_header_token() 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): return self @@ -85,7 +147,7 @@ class Pingen(object): if self._session: 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 Add necessary boilerplate to call pingen.com API @@ -96,110 +158,159 @@ class Pingen(object): :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": complete_url = "{}{}{}{}{}".format( p_url, "/id/", kwargs["params"]["id"], "/token/", self._token ) else: - complete_url = "{}{}{}".format(p_url, "/token/", self._token) - - response = method(complete_url, **kwargs) - - if response.json()["error"]: - raise APIError( - "%s: %s" - % (response.json()["errorcode"], response.json()["errormessage"]) + complete_url = p_url.format( + organisationId=self.organization, letterId=letter_id + ) + response = method(complete_url, verify=False, **kwargs) + errors = response.json().get("errors") + if errors: + raise APIError( + "\n".join( + [ + "%s (%s): %s" + % (err.get("code"), err.get("title"), err.get("detail")) + for err in errors + ] + ) ) - 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 :param str filename: name of the file to push :param StringIO filestream: file to push :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 - 1 = Priority, 2 = Economy - :param int/str color: type of print, 0 = B/W, 1 = Color + :param str delivery_product: sending product of the document if it is send + :param str print_spectrum: type of print, grayscale or color :return: tuple with 3 items: 1. document_id on pingen.com 2. post_id on pingen.com if it has been sent or None 3. dict of the created item on pingen (details) """ - data = { - "send": send, - "speed": speed, - "color": color, - } # we cannot use the `files` param alongside # with the `datas`param when data is a # JSON-encoded data. We have to construct # the entire body and send it to `data` # https://github.com/kennethreitz/requests/issues/950 - formdata = { - "file": (filename, filestream.read()), - "data": json.dumps(data), + # formdata = { + # 'file': (filename, filestream.read()), + # } + + 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( self.session.post, - "document/upload", - headers={"Content-Type": content_type}, - data=multipart, + "organisations/{organisationId}/letters", + headers={"Content-Type": "application/vnd.api+json"}, + 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"] - 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, False, item - return document_id, posted_id, item - - def send_document(self, document_id, speed=None, color=None): + def send_document( + self, document_uuid, delivery_product=None, print_spectrum=None, print_mode=None + ): """Send a uploaded document to pingen.com - :param int document_id: id of the document to send - :param int/str speed: sending speed of the document if it is send - 1 = Priority, 2 = Economy - :param int/str color: type of print, 0 = B/W, 1 = Color + :param str document_uuid: id of the document to send + :param str delivery_product: sending product of the document + :param str print_spectrum: type of print, grayscale or color :return: id of the post on pingen.com """ + data_attributes = { + "delivery_product": delivery_product, + "print_mode": print_mode, + "print_spectrum": print_spectrum, + } data = { - "speed": speed, - "color": color, + "data": { + "id": document_uuid, + "type": "letters", + "attributes": data_attributes, + } } response = self._send( - self.session.post, - "document/send", - params={"id": document_id}, - data={"data": json.dumps(data)}, + self.session.patch, + "organisations/{organisationId}/letters/{letterId}/send", + letter_id=document_uuid, + 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, post_id): + def post_infos(self, document_uuid): """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 """ - response = self._send(self.session.get, "document/get", params={"id": post_id}) - - return response.json()["item"] - - @staticmethod - def is_posted(post_infos): - """return True if the post has been sent - - :param dict post_infos: post infos returned by `post_infos` - """ - return post_infos["status"] == 1 + response = self._send( + self.session.get, + "organisations/{organisationId}/letters/{letterId}", + letter_id=document_uuid, + ) + return response.json().get("data", {}).get("attributes") + # return response.json()['item'] diff --git a/pingen/models/pingen_document.py b/pingen/models/pingen_document.py index e54c6a0..ec9ba4c 100644 --- a/pingen/models/pingen_document.py +++ b/pingen/models/pingen_document.py @@ -3,15 +3,16 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). import logging +from itertools import groupby from cStringIO import StringIO -from requests.exceptions import ConnectionError +from oauthlib.oauth2.rfc6749.errors import OAuth2Error import odoo -from odoo import _, fields, models +from odoo import _, api, fields, models 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__) @@ -26,6 +27,7 @@ class PingenDocument(models.Model): _name = "pingen.document" _inherits = {"ir.attachment": "attachment_id"} + _order = "push_date desc, id desc" attachment_id = fields.Many2one( "ir.attachment", "Document", required=True, readonly=True, ondelete="cascade" @@ -36,6 +38,7 @@ class PingenDocument(models.Model): ("pushed", "Pushed"), ("sendcenter", "In Sendcenter"), ("sent", "Sent"), + ("error_undeliverable", "Undeliverable"), ("error", "Connection Error"), ("pingen_error", "Pingen Error"), ("canceled", "Canceled"), @@ -45,26 +48,58 @@ class PingenDocument(models.Model): required=True, 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) # for `error` and `pingen_error` states when we push last_error_message = fields.Text("Error Message", readonly=True) - # pingen IDs - pingen_id = fields.Integer( - "Pingen ID", readonly=True, help="ID of the document in the Pingen Documents" - ) - post_id = fields.Integer( - "Pingen Post ID", - readonly=True, - help="ID of the document in the Pingen Sendcenter", - ) + # pingen API v2 fields + pingen_uuid = fields.Char(readonly=True) + pingen_status = fields.Char(readonly=True) # sendcenter infos - post_status = fields.Char("Post Status", size=128, readonly=True) parsed_address = fields.Text("Parsed Address", readonly=True) cost = fields.Float("Cost", readonly=True) currency_id = fields.Many2one("res.currency", "Currency", readonly=True) country_id = fields.Many2one("res.country", "Country", readonly=True) send_date = fields.Datetime("Date of sending", readonly=True) pages = fields.Integer("Pages", readonly=True) + company_id = fields.Many2one(related="attachment_id.company_id") _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): """Push a document to pingen.com :param Pingen pingen: optional pingen object to reuse session """ decoded_document = self.attachment_id._decoded_content() if pingen is None: - pingen = self._get_pingen_session() + pingen = self.company_id._get_pingen_client() try: doc_id, post_id, infos = pingen.push_document( - self.datas_fname, + self.name, StringIO(decoded_document), - self.pingen_send, - self.pingen_speed, - self.pingen_color, + self.attachment_id.mimetype, + self.auto_send, + self.delivery_product, + self.print_spectrum, + self.print_mode, ) - except ConnectionError: + except OAuth2Error as e: _logger.exception( - "Connection Error when pushing Pingen Document %s to %s." - % (self.id, pingen.url) + "Connection Error when pushing Pingen Document with ID %s to %s: %s" + % (self.id, pingen.api_url, e.description) ) raise except APIError: _logger.error( "API Error when pushing Pingen Document %s to %s." - % (self.id, pingen.url) + % (self.id, pingen.api_url) ) raise error = False state = "pushed" - if post_id: - state = "sendcenter" - elif infos["requirement_failure"]: - state = "pingen_error" - error = _("The document does not meet the Pingen requirements.") - push_date = pingen_datetime_to_utc(infos["date"]) + # if post_id: + # state = 'sendcenter' + # elif infos['requirement_failure']: + # state = 'pingen_error' + # error = _('The document does not meet the Pingen requirements.') + push_date = pingen_datetime_to_utc(infos.get("created_at")) self.write( { "last_error_message": error, "state": state, "push_date": fields.Datetime.to_string(push_date), - "pingen_id": doc_id, - "post_id": post_id, - }, + "pingen_uuid": doc_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): """Push a document to pingen.com @@ -134,31 +167,24 @@ class PingenDocument(models.Model): state = False error_msg = False try: - session = self._get_pingen_session() + session = self.company_id._get_pingen_client() self._push_to_pingen(pingen=session) - except ConnectionError: + except OAuth2Error: state = "error" error_msg = ( - _( - "Connection Error when asking for " - "sending the document %s to Pingen" - ) - % self.name + _("Connection Error when pushing document %s to Pingen") % self.name ) except APIError as e: 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 when asking Pingen to send the document %s: " "\n%s" + "Unexpected Error when pushing the document %s to Pingen:\n%s" ) % (self.name, e) - except Exception: - _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 - ) + _logger.exception(error_msg) finally: if 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) # Instead of raising, store the error in the pingen.document self = self.with_env(new_env) - not_sent_docs = self.search([("state", "!=", "sent")]) - for document in not_sent_docs: - session = document._get_pingen_session() - if document.state == "error": - document._resolve_error() - document.refresh() - try: - if document.state == "pending": - document._push_to_pingen(pingen=session) - elif document.state == "pushed": - document._ask_pingen_send(pingen=session) - except ConnectionError as e: - document.write({"last_error_message": e, "state": "error"}) - except APIError as e: - document.write( - {"last_error_message": e, "state": "pingen_error"} - ) - except BaseException: - _logger.error("Unexpected error in pingen cron") + not_sent_docs = self.search( + [("state", "!=", "sent")], order="company_id" + ) + for company, documents in groupby( + not_sent_docs, lambda d: d.company_id + ): + session = company._get_pingen_client() + for document in documents: + if document.state == "error": + document._resolve_error() + document.refresh() + try: + if document.state == "pending": + document._push_to_pingen(pingen=session) + elif document.state == "pushed" and not document.auto_send: + document._ask_pingen_send(pingen=session) + except OAuth2Error as e: + document.write({"last_error_message": e, "state": "error"}) + except APIError as e: + document.write( + {"last_error_message": e, "state": "pingen_error"} + ) + except BaseException: + _logger.error("Unexpected error in pingen cron") return True def _resolve_error(self): """A document as resolved, put in the correct state""" - if self.post_id: - state = "sendcenter" - elif self.pingen_id: + if self.send_date: + state = "sent" + elif self.pingen_uuid: state = "pushed" else: state = "pending" @@ -225,32 +256,34 @@ class PingenDocument(models.Model): """For a document already pushed to pingen, ask to send it. :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: - post_id = pingen.send_document( - self.pingen_id, self.pingen_speed, self.pingen_color + infos = pingen.send_document( + self.pingen_uuid, + self.delivery_product, + self.print_spectrum, + self.print_mode, ) - except ConnectionError: + except OAuth2Error: _logger.exception( "Connection Error when asking for sending Pingen Document %s " - "to %s." % (self.id, pingen.url) + "to %s." % (self.id, pingen.api_url) ) raise except APIError: _logger.exception( "API Error when asking for sending Pingen Document %s to %s." - % (self.id, pingen.url) + % (self.id, pingen.api_url) ) raise 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( - "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 @@ -261,9 +294,9 @@ class PingenDocument(models.Model): """ self.ensure_one() try: - session = self._get_pingen_session() + session = self.company_id._get_pingen_client() self._ask_pingen_send(pingen=session) - except ConnectionError: + except OAuth2Error: raise UserError( _( "Connection Error when asking for " @@ -289,40 +322,64 @@ class PingenDocument(models.Model): ) return True - def _update_post_infos(self, pingen): + def _get_and_update_post_infos(self, pingen): """Update the informations from pingen of a document in the Sendcenter :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 try: - post_infos = pingen.post_infos(self.pingen_id) - except ConnectionError: + post_infos = pingen.post_infos(self.pingen_uuid) + except OAuth2Error: _logger.exception( "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 except APIError: _logger.exception( "API Error when asking for sending Pingen Document %s to %s." - % (self.id, pingen.url) + % (self.id, pingen.api_url) ) raise - country = self.env["res.country"].search([("code", "=", post_infos["country"])]) - send_date = pingen_datetime_to_utc(post_infos["date"]) + return post_infos + + @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 = { - "post_status": POST_SENDING_STATUS[post_infos["status"]], - "parsed_address": post_infos["address"], + "pingen_status": post_infos.get("status"), + "parsed_address": post_infos.get("address"), "country_id": country.id, - "send_date": fields.Datetime.to_string(send_date), - "pages": post_infos["pages"], + "pages": post_infos.get("file_pages"), "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" - 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) def _update_post_infos_cron(self): @@ -338,10 +395,10 @@ class PingenDocument(models.Model): self = self.with_env(new_env) pushed_docs = self.search([("state", "!=", "sent")]) for document in pushed_docs: - session = document._get_pingen_session() + session = document.company_id._get_pingen_client() try: - document._update_post_infos(pingen=session) - except (ConnectionError, APIError): + document._get_and_update_post_infos(pingen=session) + except (OAuth2Error, APIError): # will be retried the next time # In any case, the error has been # logged by _update_post_infos @@ -358,9 +415,9 @@ class PingenDocument(models.Model): """ self.ensure_one() try: - session = self._get_pingen_session() - self._update_post_infos(pingen=session) - except ConnectionError: + session = self.company_id._get_pingen_client() + self._get_and_update_post_infos(pingen=session) + except OAuth2Error: raise UserError( _( "Connection Error when updating the status " diff --git a/pingen/models/res_company.py b/pingen/models/res_company.py index e236c96..071a5f9 100644 --- a/pingen/models/res_company.py +++ b/pingen/models/res_company.py @@ -11,10 +11,25 @@ class ResCompany(models.Model): _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") def _pingen(self): """Return a Pingen instance to work on""" 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() diff --git a/pingen/views/base_config_settings.xml b/pingen/views/base_config_settings.xml index ea1a0dc..bb7bd5f 100644 --- a/pingen/views/base_config_settings.xml +++ b/pingen/views/base_config_settings.xml @@ -7,8 +7,11 @@ - - + + + + + diff --git a/pingen/views/ir_attachment_view.xml b/pingen/views/ir_attachment_view.xml index 3bd5aeb..5adc0a0 100644 --- a/pingen/views/ir_attachment_view.xml +++ b/pingen/views/ir_attachment_view.xml @@ -9,15 +9,6 @@ - - - diff --git a/pingen/views/pingen_document_view.xml b/pingen/views/pingen_document_view.xml index c70c93a..4142a8c 100644 --- a/pingen/views/pingen_document_view.xml +++ b/pingen/views/pingen_document_view.xml @@ -7,12 +7,13 @@ tree + - - - - + + + + @@ -24,6 +25,34 @@
+
- + - + - - - - - - + + + + + - - + - + + + + + + + + + + + - - - + + - + - + - - - - - - + + - - - - -