Initial commit: OCA Report packages (45 packages)

This commit is contained in:
Ernad Husremovic 2025-08-29 15:43:05 +02:00
commit 2f4db400df
2543 changed files with 469120 additions and 0 deletions

View file

@ -0,0 +1,5 @@
from . import ir_attachment
from . import pingen
from . import pingen_document
from . import res_company
from . import base_config_settings

View file

@ -0,0 +1,34 @@
# Copyright 2012-2023 Camptocamp SA
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = "res.config.settings"
pingen_clientid = fields.Char(
string="Pingen Client ID", related="company_id.pingen_clientid", readonly=False
)
pingen_client_secretid = fields.Char(
string="Pingen Client Secret ID",
related="company_id.pingen_client_secretid",
readonly=False,
)
pingen_organization = fields.Char(
string="Pingen organization",
related="company_id.pingen_organization",
readonly=False,
)
pingen_webhook_secret = fields.Char(
string="Pingen webhook secret",
related="company_id.pingen_webhook_secret",
readonly=False,
)
pingen_staging = fields.Boolean(
string="Pingen Staging", related="company_id.pingen_staging", readonly=False
)
pingen_ssl_verify = fields.Boolean(
string="Pingen SSL Verification",
related="company_id.pingen_ssl_verify",
readonly=False,
)

View file

@ -0,0 +1,89 @@
# Author: Guewen Baconnier
# Copyright 2012-2023 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import base64
import requests
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class IrAttachment(models.Model):
_inherit = "ir.attachment"
send_to_pingen = fields.Boolean("Send to Pingen.com")
pingen_document_ids = fields.One2many(
"pingen.document", "attachment_id", string="Pingen Document", readonly=True
)
def _prepare_pingen_document_vals(self):
return {
"attachment_id": self.id,
}
def _handle_pingen_document(self):
"""Reponsible of the related ``pingen.document``
when the ``send_to_pingen``
field is modified.
Only one pingen document can be created per attachment.
When ``send_to_pingen`` is activated:
* Create a ``pingen.document`` if it does not already exist
* Put the related ``pingen.document`` to ``pending``
if it already exist
When it is deactivated:
* Do nothing if no related ``pingen.document`` exists
* Or cancel it
* If it has already been pushed to pingen.com, raises
an `osv.except_osv` exception
"""
pingen_document_obj = self.env["pingen.document"]
document = self.pingen_document_ids[0] if self.pingen_document_ids else None
if self.send_to_pingen:
if document:
document.write({"state": "pending"})
else:
pingen_document_obj.create(self._prepare_pingen_document_vals())
else:
if document:
if document.state == "pushed":
raise UserError(
_(
"Error. The attachment %s is "
"already pushed to pingen.com."
)
% self.name
)
document.write({"state": "canceled"})
return
@api.model
def create(self, vals):
attachment = super(IrAttachment, self).create(vals)
if "send_to_pingen" in vals:
attachment._handle_pingen_document()
return attachment
def write(self, vals):
res = super(IrAttachment, self).write(vals)
if "send_to_pingen" in vals:
for attachment in self:
attachment._handle_pingen_document()
return res
def _decoded_content(self):
"""Returns the decoded content of an attachment (stored or url)
Returns None if the type is 'url' and the url is not reachable.
"""
decoded_document = None
if self.type == "binary":
decoded_document = base64.b64decode(self.datas)
elif self.type == "url":
response = requests.get(self.url, timeout=30)
if response.ok:
decoded_document = requests.content
else:
raise UserError(_("The type of attachment %s is not handled") % self.type)
return decoded_document

View file

@ -0,0 +1,296 @@
# Author: Guewen Baconnier
# Copyright 2012-2023 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import json
import logging
from datetime import datetime
from urllib.parse import urljoin
import pytz
import requests
from dateutil import parser
from oauthlib.oauth2 import BackendApplicationClient
from requests_oauthlib import OAuth2Session
_logger = logging.getLogger(__name__)
def pingen_datetime_to_utc(dt):
"""Convert a date/time used by pingen.com to UTC timezone
:param dt: pingen date/time iso string (as received from the API)
to convert to UTC
:return: TZ naive datetime in the UTC timezone
"""
utc = pytz.utc
localized_dt = parser.parse(dt)
return localized_dt.astimezone(utc).replace(tzinfo=None)
class PingenException(RuntimeError):
"""There was an ambiguous exception that occurred while handling your
request."""
class APIError(PingenException):
"""An Error occured with the pingen API"""
class Pingen(object):
"""Interface to the pingen.com API"""
def __init__(self, clientid, secretid, organization, staging=True, verify=True):
self.clientid = clientid
self.secretid = secretid
self.organization = organization
self.staging = staging
self.verify = verify
self._session = None
self._init_token_registry()
super(Pingen, self).__init__()
@property
def api_url(self):
if self.staging:
return "https://api-staging.pingen.com"
return "https://api.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
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 = urljoin(self.identity_url, self.token_url)
_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=self.verify,
)
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
def __exit__(self, *args):
self.close()
def close(self):
"""Dispose of any internal state."""
if self._session:
self._session.close()
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
(authentication, configuration, ...)
:param boundmethod method: requests method to call
:param str endpoint: endpoint to call
:param kwargs: additional arguments forwarded to the requests method
"""
if self._is_token_expired():
self._set_session_header_token()
p_url = urljoin(self.api_url, endpoint)
if endpoint == "document/get":
complete_url = "{}{}{}{}{}".format(
p_url, "/id/", kwargs["params"]["id"], "/token/", self._token
)
else:
complete_url = p_url.format(
organisationId=self.organization, letterId=letter_id
)
response = method(complete_url, verify=self.verify, **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 _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}, timeout=30
)
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 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)
"""
url, url_signature = self._get_file_upload()
self.upload_file(url, filestream.read(), content_type)
data_attributes = {
"file_original_name": filename,
"file_url": url,
"file_url_signature": url_signature,
"address_position": "left",
"auto_send": send,
"delivery_product": delivery_product,
"print_spectrum": print_spectrum,
"print_mode": print_mode,
}
data = {"data": {"type": "letters", "attributes": data_attributes}}
response = self._send(
self.session.post,
"organisations/{organisationId}/letters",
headers={"Content-Type": "application/vnd.api+json"},
data=json.dumps(data),
)
rjson_data = response.json().get("data", {})
document_id = rjson_data.get("id")
item = rjson_data.get("attributes")
return document_id, False, item
def send_document(
self, document_uuid, delivery_product=None, print_spectrum=None, print_mode=None
):
"""Send a uploaded document to pingen.com
: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 = {
"data": {
"id": document_uuid,
"type": "letters",
"attributes": data_attributes,
}
}
response = self._send(
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")
def post_infos(self, document_uuid):
"""Return the information of a post
:param str document_uuid: id of the document to send
:return: dict of infos of the post
"""
response = self._send(
self.session.get,
"organisations/{organisationId}/letters/{letterId}",
letter_id=document_uuid,
)
return response.json().get("data", {}).get("attributes")

View file

@ -0,0 +1,442 @@
# Author: Guewen Baconnier
# Copyright 2012-2023 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
import logging
from io import BytesIO
from itertools import groupby
from oauthlib.oauth2.rfc6749.errors import OAuth2Error
import odoo
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from .pingen import APIError, pingen_datetime_to_utc
_logger = logging.getLogger(__name__)
class PingenDocument(models.Model):
"""A pingen document is the state of the synchronization of
an attachment with pingen.com
It stores the configuration and the current state of the synchronization.
It also serves as a queue of documents to push to pingen.com
"""
_name = "pingen.document"
_description = "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"
)
state = fields.Selection(
[
("pending", "Pending"),
("pushed", "Pushed"),
("sendcenter", "In Sendcenter"),
("sent", "Sent"),
("error_undeliverable", "Undeliverable"),
("error", "Connection Error"),
("pingen_error", "Pingen Error"),
("canceled", "Canceled"),
],
readonly=True,
required=True,
default="pending",
)
auto_send = fields.Boolean(
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")],
default="grayscale",
)
print_mode = fields.Selection(
[("simplex", "Simplex"), ("duplex", "Duplex")], "Print mode", default="simplex"
)
push_date = fields.Datetime(readonly=True)
# for `error` and `pingen_error` states when we push
last_error_message = fields.Text("Error Message", readonly=True)
# pingen API v2 fields
pingen_uuid = fields.Char(readonly=True)
pingen_status = fields.Char(readonly=True)
# sendcenter infos
parsed_address = fields.Text(readonly=True)
cost = fields.Float(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(readonly=True)
company_id = fields.Many2one(related="attachment_id.company_id")
_sql_constraints = [
(
"pingen_document_attachment_uniq",
"unique (attachment_id)",
"Only one Pingen document is allowed per attachment.",
),
]
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.company_id._get_pingen_client()
try:
doc_id, post_id, infos = pingen.push_document(
self.name,
BytesIO(decoded_document),
self.attachment_id.mimetype,
self.auto_send,
self.delivery_product,
self.print_spectrum,
self.print_mode,
)
except OAuth2Error as e:
_logger.exception(
"Connection Error when pushing Pingen Document with ID %s to %s: %s"
% (self.id, pingen.api_url, e.description)
)
raise
except APIError as e:
_logger.error(
"API Error when pushing Pingen Document %s to %s: %s"
% (self.id, pingen.api_url, str(e))
)
raise
error = False
state = "pushed"
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_uuid": doc_id,
"pingen_status": infos.get("status"),
}
)
_logger.info("Pingen Document %s: pushed to %s" % (self.id, pingen.api_url))
def push_to_pingen(self):
"""Push a document to pingen.com
Convert errors to osv.except_osv to be handled by the client.
Wrapper method for multiple ids (when triggered from button for
instance) for public interface.
"""
self.ensure_one()
state = False
error_msg = False
try:
session = self.company_id._get_pingen_client()
self._push_to_pingen(pingen=session)
except OAuth2Error:
state = "error"
error_msg = (
_("Connection Error when pushing document %s to Pingen") % self.name
)
except APIError as e:
state = "pingen_error"
error_msg = _(
"Error when pushing the document %(name) to Pingen:\n%(exc)"
) % {
"name": self.name,
"exc": str(e),
}
except Exception as e:
error_msg = _(
"Unexpected Error when pushing the document %(name) to Pingen:\n%(exc)"
) % {"name": self.name, "exc": e}
_logger.exception(error_msg)
finally:
if error_msg:
vals = {"last_error_message": error_msg}
if state:
vals.update({"state": state})
with odoo.registry(self.env.cr.dbname).cursor() as new_cr:
new_env = odoo.api.Environment(
new_cr, self.env.uid, self.env.context
)
self.with_env(new_env).write(vals)
raise UserError(error_msg)
return True
def _push_and_send_to_pingen_cron(self):
"""Push a document to pingen.com
Intended to be used in a cron.
Commit after each record
Instead of raising, store the error in the pingen.document
"""
with odoo.api.Environment.manage():
with odoo.registry(self.env.cr.dbname).cursor() as new_cr:
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")], 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.invalidate_recordset()
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.send_date:
state = "sent"
elif self.pingen_uuid:
state = "pushed"
else:
state = "pending"
self.write({"state": state})
def resolve_error(self):
"""A document as resolved, put in the correct state"""
for document in self:
document._resolve_error()
return True
def _ask_pingen_send(self, pingen):
"""For a document already pushed to pingen, ask to send it.
:param Pingen pingen: pingen object to reuse
"""
try:
infos = pingen.send_document(
self.pingen_uuid,
self.delivery_product,
self.print_spectrum,
self.print_mode,
)
except OAuth2Error:
_logger.exception(
"Connection Error when asking for 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.api_url)
)
raise
self.write(
{
"last_error_message": False,
"state": "sendcenter",
"pingen_status": infos.get("status"),
}
)
_logger.info(
"Pingen Document %s: asked for sending to %s" % (self.id, pingen.api_url)
)
return True
def ask_pingen_send(self):
"""For a document already pushed to pingen, ask to send it.
Wrapper method for multiple ids (when triggered from button for
instance) for public interface.
"""
self.ensure_one()
try:
session = self.company_id._get_pingen_client()
self._ask_pingen_send(pingen=session)
except OAuth2Error as e:
raise UserError(
_(
"Connection Error when asking for "
"sending the document %s to Pingen"
)
% self.name
) from e
except APIError as e:
raise UserError(
_("Error when asking Pingen to send the document %(name): " "\n%(exc)")
% {"name": self.name, "exc": e}
) from e
except BaseException as e:
_logger.exception(
"Unexpected Error when updating the status "
"of pingen.document %s: " % self.id
)
raise UserError(
_("Unexpected Error when updating the status " "of Document %s")
% self.name
) from e
return True
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
"""
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_uuid)
except OAuth2Error:
_logger.exception(
"Connection Error when asking for "
"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.api_url)
)
raise
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 = {
"pingen_status": post_infos.get("status"),
"parsed_address": post_infos.get("address"),
"country_id": country.id,
"pages": post_infos.get("file_pages"),
"last_error_message": False,
"cost": post_infos.get("price_value"),
"currency_id": currency.id,
}
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"
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):
"""Update the informations from pingen of a
document in the Sendcenter
Intended to be used in a cron.
Commit after each record
Do not raise errors, only skip the update of the record."""
with odoo.api.Environment.manage():
with odoo.registry(self.env.cr.dbname).cursor() as new_cr:
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)
pushed_docs = self.search([("state", "!=", "sent")])
for document in pushed_docs:
session = document.company_id._get_pingen_client()
try:
document._get_and_update_post_infos(pingen=session)
# pylint: disable=W7938
# pylint: disable=W8138
except (OAuth2Error, APIError):
# will be retried the next time
# In any case, the error has been
# logged by _update_post_infos
pass
except BaseException as e:
_logger.error("Unexcepted error in pingen cron: %", e)
raise
return True
def update_post_infos(self):
"""Update the informations from pingen of a document in the Sendcenter
Wrapper method for multiple ids (when triggered from button for
instance) for public interface.
"""
self.ensure_one()
try:
session = self.company_id._get_pingen_client()
self._get_and_update_post_infos(pingen=session)
except OAuth2Error as e:
raise UserError(
_(
"Connection Error when updating the status "
"of Document %s from Pingen"
)
% self.name
) from e
except APIError as e:
raise UserError(
_(
"Error when updating the status of Document %(name) from "
"Pingen: \n%(exc)"
)
% {"name": self.name, "exc": e}
) from e
except BaseException as e:
_logger.exception(
"Unexpected Error when updating the status "
"of pingen.document %s: " % self.id
)
raise UserError(
_("Unexpected Error when updating the status " "of Document %s")
% self.name
) from e
return True

View file

@ -0,0 +1,37 @@
# Author: Guewen Baconnier
# Copyright 2012-2023 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).
from odoo import fields, models
from .pingen import Pingen
class ResCompany(models.Model):
_inherit = "res.company"
pingen_clientid = fields.Char(size=20)
pingen_client_secretid = fields.Char(size=80)
pingen_organization = fields.Char("Pingen organization ID")
pingen_webhook_secret = fields.Char()
pingen_staging = fields.Boolean()
pingen_ssl_verify = fields.Boolean(default=True)
def _pingen(self):
"""Return a Pingen instance to work on"""
self.ensure_one()
clientid = self.pingen_clientid
secretid = self.pingen_client_secretid
return Pingen(
clientid,
secretid,
organization=self.pingen_organization,
staging=self.pingen_staging,
verify=self.pingen_ssl_verify,
)
def _get_pingen_client(self):
"""Returns a pingen session for a user"""
return self._pingen()