diff --git a/usability_webhooks/README.rst b/usability_webhooks/README.rst new file mode 100644 index 00000000..8c9da56c --- /dev/null +++ b/usability_webhooks/README.rst @@ -0,0 +1,220 @@ +==================== +REST API for Webhook +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:118b3dcbe0ecd2d2813ecb16117bddb07226db7b09f60de3924b570ea6e2dfc4 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-ecosoft--odoo%2Fecosoft--addons-lightgray.png?logo=github + :target: https://github.com/ecosoft-odoo/ecosoft-addons/tree/18.0/usability_webhooks + :alt: ecosoft-odoo/ecosoft-addons + +|badge1| |badge2| |badge3| + +This module is base webhooks standard and keep all log that interface + +Step to see logs: + +1. Go to Settings > Technical > API Configuration > API Logs +2. this table will keep all log that interface '/api/create_data' or '/api/create_update_data' +3. Users can used this table for test API by click 'Update API' + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Before sending a REST API request to Odoo, an initial call to authenticate the API is necessary. +You can achieve this by calling the ``/web/session/authenticate`` route. + +The authentication format requires a header with ``Content-type`` set to ``application/json``, +and the body should include: + +.. code-block:: python + + { + "jsonrpc": "2.0", + "method": "call", + "params": { + "db": "", + "login": "", + "password": "" + } + } + +Following successful authentication, you can proceed with five API routes: + +1. ``/api/create_data``: This route allows the creation of new data only. + The format for creating data should be in the following structure: + + .. code-block:: python + + { + "params": { + "model": "", + "vals": { + "payload": { + "field1": "value1", + ... + }, + "result_field": ["field1", ...] # optional + } + } + } + +2. ``/api/create_update_data``: This route facilitates updating data. + If the data does not exist, it will automatically create it. + The format follows that of ``create_data``, but it requires a unique key in the field to update the values. + + .. code-block:: python + + { + "params": { + "model": "", + "vals": { + "search_key": { + "": "value", # can be ID or name search string + }, + "payload": { + "field1": "value1", + ... + }, + "result_field": ["field1", ...] # optional + } + } + } + +3. ``/api/update_data``: This route allows updating existing data, + using a unique key in the field to find the desired data and update values in that recordset. + + .. code-block:: python + + { + "params": { + "model": "", + "vals": { + "search_key": { + "": "value", # can be ID or name search string + }, + "payload": { + "field1": "value1", + ... + } + } + } + } + +4. ``/api/search_data``: This route allows you to search for the value of a desired field in a model + by using a search domain to find the desired recordset. You can also limit and order the resulting data. + + .. code-block:: python + + { + "params": { + "model": "", + "vals": { + "payload": { + "search_field": ["field1", "field2", "field3{subfield1, subfield2}", ...], + "search_domain": "[('field', 'operator', 'value')]", + "limit": 1, + "order": "field1 , field2 desc, ..." + } + } + } + } + +5. ``/api/call_function``: This route allows you to call a function on a model object based on the provided input. + + **Parameters**: + - **name** (*str*): The name of the model to perform the function on. + - **method** (*str*): The name of the function to call. + - **parameter** (*dict*): A dictionary containing the arguments to pass to the function (if any). + + .. code-block:: python + + { + "params": { + "model": "", + "vals": { + "search_key": { + "": "value", # can be ID or name search string + }, + "payload": { + "method": "", + "parameter": {"": "", ...} + } + } + } + } + +**Note**: +If you want to attach a file to a record, you can add the key "attachment_ids" at any level of the payload. + + **Example Request with Attachment**: + + .. code-block:: python + + { + "params": { + "model": "", + "vals": { + "search_key": { + "": "value", # can be ID or name search string + }, + "payload": { + "attachment_ids": [ + { + "name": "", + "datas": "" + } + ], + ... + } + } + } + } + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Ecosoft + +Contributors +~~~~~~~~~~~~ + +* Kitti Upariphutthiphong +* Saran Lim. + +Maintainers +~~~~~~~~~~~ + +This module is part of the `ecosoft-odoo/ecosoft-addons `_ project on GitHub. + +You are welcome to contribute. diff --git a/usability_webhooks/__init__.py b/usability_webhooks/__init__.py new file mode 100644 index 00000000..c55325ea --- /dev/null +++ b/usability_webhooks/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import controllers +from . import models diff --git a/usability_webhooks/__manifest__.py b/usability_webhooks/__manifest__.py new file mode 100644 index 00000000..49ca5c94 --- /dev/null +++ b/usability_webhooks/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2022 Ecosoft Co., Ltd. (http://ecosoft.co.th) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "REST API for Webhook", + "version": "18.0.1.0.0", + "license": "AGPL-3", + "category": "Tools", + "author": "Ecosoft, Odoo Community Association (OCA)", + "website": "https://github.com/ecosoft-odoo/ecosoft-addons", + "depends": ["base", "mail"], + "data": [ + "security/ir.model.access.csv", + "data/config_parameter.xml", + "data/ir_cron.xml", + "views/api_log.xml", + ], +} diff --git a/usability_webhooks/controllers/__init__.py b/usability_webhooks/controllers/__init__.py new file mode 100644 index 00000000..748be704 --- /dev/null +++ b/usability_webhooks/controllers/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import main +from . import utils diff --git a/usability_webhooks/controllers/main.py b/usability_webhooks/controllers/main.py new file mode 100644 index 00000000..ee8e329d --- /dev/null +++ b/usability_webhooks/controllers/main.py @@ -0,0 +1,88 @@ +# Copyright 2022 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +import ast +import json +import traceback + +from odoo import http +from odoo.http import request + + +class WebhookController(http.Controller): + def _call_function_api(self, model, vals, function): + """ + This function will call the function from webhook.utils + Can be hook to add something before or after the function + """ + return getattr(request.env["webhook.utils"], function)(model, vals) + + def _create_api_logs(self, model, vals, function): + # Add logs + data_dict = { + "data": json.dumps(vals), + "model": model, + "route": f"/api/{function}", + "function_name": function, + } + + ICP = request.env["ir.config_parameter"] + rollback_state_failed = ICP.sudo().get_param("webhook.rollback_state_failed") + rollback_except = ICP.sudo().get_param("webhook.rollback_except") + try: + res = self._call_function_api(model, vals, function) + state = "done" if res["is_success"] else "failed" + data_dict.update({"result": res, "state": state}) + # Not success, rollback all data (if config in system parameter) + if not res["is_success"] and rollback_state_failed: + request.env.cr.rollback() + except Exception: + res = { + "is_success": False, + "messages": traceback.format_exc(), + } + data_dict.update({"result": res, "state": "failed"}) + # Error from odoo exception, + # rollback all data (if config in system parameter) + if rollback_except: + request.env.cr.rollback() + if vals["is_create_log"]: + request.env["api.log"].create(data_dict) + return res + + def _set_create_logs(self, param, vals): + ICP = request.env["ir.config_parameter"] + is_create_log = ICP.sudo().get_param(param) + # convert str to bool + is_create_log = ast.literal_eval(is_create_log.capitalize()) + vals.update({"is_create_log": is_create_log}) + + @http.route("/api/create_data", type="json", auth="user") + def create_data(self, model, vals): + self._set_create_logs("webhook.create_data_log", vals) + res = self._create_api_logs(model, vals, "create_data") + return res + + @http.route("/api/update_data", type="json", auth="user") + def update_data(self, model, vals): + self._set_create_logs("webhook.update_data_log", vals) + res = self._create_api_logs(model, vals, "update_data") + return res + + @http.route("/api/create_update_data", type="json", auth="user") + def create_update_data(self, model, vals): + self._set_create_logs("webhook.create_update_data_log", vals) + res = self._create_api_logs(model, vals, "create_update_data") + return res + + @http.route("/api/search_data", type="json", auth="user") + def search_data(self, model, vals): + self._set_create_logs("webhook.search_data_log", vals) + res = self._create_api_logs(model, vals, "search_data") + return res + + @http.route("/api/call_function", type="json", auth="user") + def call_function(self, model, vals): + self._set_create_logs("webhook.call_function_log", vals) + res = self._create_api_logs(model, vals, "call_function") + return res diff --git a/usability_webhooks/controllers/utils.py b/usability_webhooks/controllers/utils.py new file mode 100644 index 00000000..8e18928c --- /dev/null +++ b/usability_webhooks/controllers/utils.py @@ -0,0 +1,666 @@ +# Copyright 2022 Ecosoft Co., Ltd (http://ecosoft.co.th/) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) + +import ast +import logging +import re + +from odoo import api, models, tools +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +class WebhookUtils(models.AbstractModel): + _name = "webhook.utils" + _description = "Utils Class" + + @tools.ormcache("model", "val", "args") + def _call_name_search_cache(self, model, val, args): + # Convert args to list, ORM cache can't use in type list + args = ast.literal_eval(args) or [] + return model.name_search(val, args=args, operator="=") + + @tools.ormcache("model", "val") + def _call_search_cache(self, model, key_search, val): + # Convert val to list, ORM cache can't use in type list + val = ast.literal_eval(val) or [] + return model.search([(key_search, "in", val)]) + + def _get_o2m_line(self, line_data_dict, line_obj): + rec_fields = [] + rec_fields_append = rec_fields.append + line_fields = [] + for field, model_field in line_obj._fields.items(): + if field in line_data_dict and model_field.type != "one2many": + rec_fields_append(field) + elif field in line_data_dict: + line_fields.append(field) + line_dict = {k: v for k, v in line_data_dict.items() if k in rec_fields} + return line_dict, line_fields + + def _get_dict_attachment(self, list_attachment, model, res_id): + return [ + { + "name": attach["name"], + "res_model": model, + "res_id": res_id, + "datas": attach["datas"].encode("ascii"), + } + for attach in list_attachment + ] + + def _create_file_attachment(self, objs, data_dict, line_all_fields): + Attachment = self.env["ir.attachment"] + + def add_attachments(obj, data_dict, file_attach): + file_attach += self._get_dict_attachment( + data_dict.get("attachment_ids", []), obj._name, obj.id + ) + for line_field, line_data in data_dict.items(): + if isinstance(line_data, list) and line_field in obj: + for i, obj_line in enumerate(obj[line_field]): + line_data_dict = line_data[i] + add_attachments(obj_line, line_data_dict, file_attach) + + file_attach = [] + for obj in objs: + add_attachments(obj, data_dict, file_attach) + + if file_attach: + Attachment.create(file_attach) + + def process_lines(self, rec, data_dict, auto_create): + final_line_dict = [] + final_line_append = final_line_dict.append + + for line_data_dict in data_dict: + line_dict, line_fields = self._get_o2m_line(line_data_dict, rec) + line_dict = self._finalize_data_to_write(rec, line_dict, auto_create) + + for line_sub_field in line_fields: + if line_sub_field in line_data_dict: + sub_line_dicts = self.process_lines( + rec[line_sub_field], line_data_dict[line_sub_field], auto_create + ) + line_dict.update({line_sub_field: sub_line_dicts}) + + final_line_append((0, 0, line_dict)) + + return final_line_dict + + def _convert_data_to_id(self, model, vals): + data_dict = vals.get("payload", {}) + auto_create = vals.get("auto_create", {}) + rec = self.env[model].new() # Dummy record + rec_fields = [] + line_all_fields = [] + for field, model_field in rec._fields.items(): + if field in data_dict and model_field.type != "one2many": + rec_fields.append(field) + elif field in data_dict: + line_all_fields.append(field) + rec_dict = {k: v for k, v in data_dict.items() if k in rec_fields} + rec_dict = self._finalize_data_to_write(rec, rec_dict, auto_create) + + # Prepare Line Dict (o2m) + for line_field in line_all_fields: + rec_dict[line_field] = self.process_lines( + rec[line_field], data_dict[line_field], auto_create + ) + + return rec_dict, rec, line_all_fields + + @api.model + def friendly_create_data(self, model, vals): + """Accept friendly data_dict in following format to create data, + and auto_create data if found no match. + ------------------------------------------------------------- + vals: + { + 'payload': { + 'field1': value1, + 'field2_id': value2, # can be ID or name search string + 'attachment_ids': [ # for attach file + { + 'name': value3, + 'datas': value4, + } + ] + 'line_ids': [ + { + 'field3': value5, + 'field4_id': value6, # can be ID or name search string + }, + 'attachment_ids': [ # for attach file in line + { + 'name': value7, + 'datas': value8, + } + ], + {..new record..}, {..new record..}, ... + ], + }, + 'auto_create': { + 'field2_id': {'name': 'some name', ...}, + 'field4_id': {'name': 'some name', ...}, + # If more than 1 value, you can use list instead + # 'field4_id': [{'name': 'some name', ...}, {...}, {...}] + } + } + """ + data_dict = vals.get("payload", {}) + rec_dict, rec, line_all_fields = self._convert_data_to_id(model, vals) + company_id = rec_dict.get("company_id") or self.env.company.id + # Send context to function create() + obj = rec.with_context( + api_payload=data_dict, default_company_id=company_id + ).create(rec_dict) + # Create Attachment (if any) + self._create_file_attachment(obj, data_dict, line_all_fields) + res = { + "is_success": True, + "result": {"id": obj.id}, + "messages": self.env._("Record created successfully"), + } + # Clear cache + self.env.registry.clear_cache() + return res + + def _search_object(self, model, vals): + search_key = vals.get("search_key", {}) + # Prepare Header Dict (non o2m) + if not search_key: + raise ValidationError( + self.env._("Parameter 'search_key' in 'vals' not found!") + ) + + search_domain = [ + (k, "in" if isinstance(v, list) else "=", v) for k, v in search_key.items() + ] + + # search record to update + return self.env[model].with_context(prefetch_fields=True).search(search_domain) + + @api.model + def friendly_update_data(self, model, vals): + """Accept friendly data_dict in following format to update existing rec + This method, will always delete o2m lines and recreate it. + ------------------------------------------------------------- + vals: + { + 'search_key': { + "": "", + }, + 'payload': { + 'field1': value1, + 'field2_id': value2, # can be ID or name search string + 'line_ids': [ + { + 'field3': value3, + 'field4_id': value4, # can be ID or name search string + }, + {..new record..}, {..new record..}, ... + ], + } + 'auto_create': { + 'field2_id': {'name': 'some name', ...}, + 'field4_id': {'name': 'some name', ...}, + # If more than 1 value, you can use list instead + # 'field4_id': [{'name': 'some name', ...}, {...}, {...}] + } + }, + """ + data_dict = vals.get("payload", {}) + auto_create = vals.get("auto_create", {}) + vals.get("search_key", {}) + + rec = self._search_object(model, vals) + + rec_fields = [] + line_all_fields = [] + + for field, model_field in rec._fields.items(): + if field in data_dict and model_field.type != "one2many": + rec_fields.append(field) + elif field in data_dict: + line_all_fields.append(field) + rec_dict = {k: v for k, v in data_dict.items() if k in rec_fields} + rec_dict = self._finalize_data_to_write(rec, rec_dict, auto_create) + + # Prepare Line Dict (o2m) + for line_field in line_all_fields: + lines = rec[line_field] + # First, delete all lines o2m + lines.unlink() + rec_dict[line_field] = self.process_lines( + rec[line_field], data_dict[line_field], auto_create + ) + + rec.write(rec_dict) + + # Create Attachment (if any) + self._create_file_attachment(rec, data_dict, line_all_fields) + res = { + "is_success": True, + "result": {"id": rec.ids}, + "messages": self.env._("Record updated successfully"), + } + return res + + def _update_child_2many(self, value, field_2many, sub_model): + search_domain = [("id", "in", value)] + return self.env[sub_model].search_read(search_domain, field_2many) + + @tools.ormcache("key", "model_obj") + def _get_sub_model(self, key, model_obj): + sub_model = model_obj._fields[key].comodel_name + return sub_model + + def _update_result_with_2many(self, result, result_dict, model_obj): + model_list = [] + for res in result: + for key, value in res.items(): + field_2many = result_dict.get(key) + # Search values that need to be displayed in the result + if field_2many: + # For case many2one, convert to list + if isinstance(value, tuple): + value = [value[0]] + + # For case reference type, convert to list + if model_obj._fields[key].type == "reference": + sub_model = value.split(",")[0] + value = [value.split(",")[1]] + else: + sub_model = self._get_sub_model(key, model_obj) + + # Recusive search for 2many fields + filtered_values = [x for x in field_2many if "{" in x] + sub_result = [] + if filtered_values: + # Update search_field without {} + field_2many = [x.split("{")[0] for x in field_2many] + sub_result = self._search_subfield(filtered_values) + + child_result = self._update_child_2many( + value, field_2many, sub_model + ) + + if filtered_values: + child_result = self._update_result_with_2many( + child_result, sub_result, self.env[sub_model] + ) + # Replace value with child result + res[key] = child_result + model_list.append(sub_model) + # Clear caches + for model in model_list: + self.env[model].env.registry.clear_cache() + return result + + def _search_subfield(self, filtered_values): + result_dict = {} + # Regular expression pattern to match 'field_name{value1, value2}' + pattern = r"([\w.-]+)\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}" + # Iterate over each item in the list + for item in filtered_values: + # Use re.match to find matches according to the pattern + match = re.match(pattern, item) + if match: + # Extract the field name and the values inside the curly braces + field_name, values_str = match.groups() + # Regular expression to match the desired pattern + matches = re.findall(r"[^,]+{[^}]+}|[^,]+", values_str) + # Stripping any leading/trailing spaces from the elements + values_list = [match.strip() for match in matches] + # Assign to the result dictionary + result_dict[field_name] = values_list + return result_dict + + def _common_search_data(self, model, vals): + """ + Search and read data from the specified model based on the provided values. + + Args: + model (str): The name of the model to search data from. + vals (dict): A dictionary containing the payload data. + + Returns: + list: A list of records matching the search criteria. + + """ + data_dict = vals.get("payload", {}) + limit = data_dict.get("limit", None) + order = data_dict.get("order", None) + # Search all fields if not specified + search_field = [] + search_domain = [] + result_dict = [] + if data_dict.get("search_field"): + search_field = data_dict["search_field"] + # Filter value with {} + filtered_values = [x for x in search_field if "{" in x] + # Update search_field without {} + search_field = [x.split("{")[0] for x in search_field] + # search sub field 'field_name{value1, value2}' + result_dict = self._search_subfield(filtered_values) + + if data_dict.get("search_domain"): + search_domain = ast.literal_eval(data_dict["search_domain"]) + + model_obj = self.env[model] + result = model_obj.search_read( + search_domain, search_field, limit=limit, order=order + ) + # Update result with 2many fields + if result_dict: + result = self._update_result_with_2many(result, result_dict, model_obj) + + return result + + def _get_search_args( + self, have_company, model, rec_dict, main_company, ignore_checkcompany_model + ): + if have_company and model not in ignore_checkcompany_model: + return str( + [("company_id", "=", rec_dict.get("company_id", main_company.id))] + ) + return "[]" + + def _auto_create_record(self, Model, val, key, auto_create, args): + new_recs = ( + auto_create[key] + if isinstance(auto_create[key], list) + else [auto_create[key]] + ) + for new_rec in new_recs: + self.friendly_create_data(Model._name, {"payload": new_rec}) + return self._call_name_search_cache(Model, val, args) + + def _process_many2_field( + self, + rec, + key, + rec_dict, + ftype, + auto_create, + ignore_checkcompany_model, + main_company, + ): + model = rec._fields[key].comodel_name + Model = self.env[model] + search_vals = [rec_dict[key]] + value = [] # for many2many, result will be tuple + have_company = hasattr(Model, "company_id") + + for val in search_vals: + # Support multi company + # orm cache can't use in type list, + # so we need to convert to string + args = self._get_search_args( + have_company, model, rec_dict, main_company, ignore_checkcompany_model + ) + if ftype == "many2many": + value = self._process_many2many_field( + Model, val, args, key, auto_create + ) + else: + value = self._process_many2one_field(Model, val, args, key, auto_create) + + return value + + def _process_many2one_field(self, Model, val, args, key, auto_create): + values = self._call_name_search_cache(Model, val, args) + + # If failed, try again by ID + if len(values) != 1 and val and isinstance(val, int): + rec = self._call_search_cache(Model, "id", str([val])) + values = [(rec.id,)] if len(rec) == 1 else values + + # Found > 1, can't continue + if len(values) > 1: + Model.env.registry.clear_cache() + raise ValidationError( + self.env._("'%(val)s' matched more than 1 record") % {"val": val} + ) + + # If not found, but auto_create it + if not values and auto_create.get(key): + values = self._auto_create_record(Model, val, key, auto_create, args) + + if not values: + Model.env.registry.clear_cache() + raise ValidationError( + self.env._("'%(key)s': '%(val)s' found no match.") + % {"key": key, "val": val} + ) + + return values[0][0] + + def _process_many2many_field(self, Model, val, args, key, auto_create): + method_many2many = 4 # default is add new line + if val.get("replace", False): + method_many2many = 6 # change to replace all + del val["replace"] + + key_search, val_search = next(iter(val.items())) + + records = self._call_search_cache(Model, key_search, str(val_search)) + if not records and auto_create.get(key): + new_recs = ( + auto_create[key] + if isinstance(auto_create[key], list) + else [auto_create[key]] + ) + for new_rec in new_recs: + self.friendly_create_data(Model._name, {"payload": new_rec}) + records = self._call_search_cache(Model, key_search, str(val_search)) + elif not records: + Model.env.registry.clear_cache() + raise ValidationError( + self.env._("'%(key)s': '%(val)s' found no match.") + % {"key": key, "val": val} + ) + if method_many2many == 6: + return [(6, 0, records.ids)] + else: + return [(4, rec.id) for rec in records] + + @api.model + def _finalize_data_to_write(self, rec, rec_dict, auto_create=False): + """For many2one, many2many, use name search to get id""" + final_dict = {} + ICP = self.env["ir.config_parameter"] + ignore_checkcompany_model = ICP.sudo().get_param( + "webhook.ignore_checkcompany_model" + ) + auto_create = auto_create or {} + main_company = self.env.company + for key, value in rec_dict.items(): + ffield = rec._fields.get(key, False) + if ffield: + ftype = ffield.type + # For performance, we only check if key in rec_dict and param is not ID + if self._is_many2_field_with_string(ftype, key, rec_dict): + value = self._process_many2_field( + rec, + key, + rec_dict, + ftype, + auto_create, + ignore_checkcompany_model, + main_company, + ) + final_dict[key] = value + return final_dict + + def _is_many2_field_with_string(self, ftype, key, rec_dict): + if ( + key in rec_dict.keys() + and ftype in ("many2one", "many2many") + and rec_dict.get(key, False) + ): + if ftype == "many2many" and isinstance(rec_dict[key], dict): + return True + if ftype == "many2one" and isinstance(rec_dict[key], str): + return True + return False + + @api.model + def create_data(self, model, vals): + _logger.info(f"[{model}].create_data(), input: {vals}") + res = self.friendly_create_data(model, vals) + if res["is_success"]: + res_id = res["result"]["id"] + p = self.env[model].browse(res_id) + result_field = vals.get("result_field", []) + for result in result_field: + res["result"][result] = p[result] + _logger.info(f"[{model}].create_data(), output: {res}") + return res + + @api.model + def update_data(self, model, vals): + _logger.info(f"[{model}].update_data(), input: {vals}") + res = self.friendly_update_data(model, vals) + if res["is_success"]: + search_key = vals.get("search_key", {}) + for key, value in search_key.items(): + res["result"][key] = value + _logger.info(f"[{model}].update_data(), output: {res}") + return res + + @api.model + def create_update_data(self, model, vals): + _logger.info(f"[{model}].create_update_data(), input: {vals}") + # Update + rec = self._search_object(model, vals) + if not rec: + return self.create_data(model, vals) # fall back to create + res = self.friendly_update_data(model, vals) + if res["is_success"]: + search_key = vals.get("search_key", {}) + for key, value in search_key.items(): + res["result"][key] = value + _logger.info(f"[{model}].create_update_data(), output: {res}") + return res + + @api.model + def search_data(self, model, vals): + """ + ================================== + Search Data Description + ================================== + This utility function facilitates querying records from a specified model + with customizable search criteria. + The search parameters include fields to fetch, filtering conditions, + record limits, and sorting orders. + + Parameters: + - search_field: + - Use an empty list `[]` to retrieve all fields from the model. + - Specify a list of field names `["", ""]` + to retrieve only those fields. + - For many2one, one2many and many2many fields, + you can specify the fields to fetch by using the following format: + `["", "{, }"]` + where `` and `` are fields from the model, + and `` and `` are fields + from the related model. + The related fields will be fetched and displayed in the result. + + - search_domain: + - Use an empty string `""` to apply no filtering conditions + (equivalent to fetching all records). + - Provide a string representation of a list of tuples + `"[('', '', '')]"` + to define filtering conditions. Each tuple should contain a field name, + an operator (e.g., '=', '>', '<'), and the value to compare against. + + - limit: + - Omit this parameter or set it to `None` + to fetch all matching records without any limit. + - Specify an integer to limit the number of records returned. + + - order: + - Omit this parameter or set it to `None` + to fetch all matching records any specific ordering. + - Provide a strings + `" asc|desc, asc|desc"` + to sort the results. Each string should specify a field name followed + by the sorting direction (`asc` for ascending, `desc` for descending). + + ================================== + Example Format for Search Data: + ================================== + { + "params": { + "model": "account.move", # Model to search + "vals": { + "payload": { + "search_field": [ + "name", "date", + "invoice_line_ids{product_id, name, account_id}" + ], + "search_domain": "[('move_type', '=', 'in_invoice')]", + "limit": 1, + "order": "date desc, name" + } + } + } + } + """ + _logger.info(f"[{model}].search_data(), input: {vals}") + result = self._common_search_data(model, vals) + res = { + "is_success": True, + "result": result, + "messages": self.env._("Record search successfully"), + } + _logger.info(f"[{model}].search_data(), output: {res}") + return res + + @api.model + def call_function(self, model, vals): + """ + Call a function on a model object based on the provided input. + Parameters (search_key) are used to search for the record: + - search_key: + A dictionary containing the search criteria to find the record. + + Parameters (payload) are used to call the function: + - method (str): The name of the function to call. + - parameter (dict): + A dictionary containing the arguments to pass to the function. (if any) + + ================================== + Example Format for Call Function: + ================================== + { + "params": { + "model": "account.move", # Model to call + "vals": { + "search_key": { + "name": "INV/2021/0001" + }, + "payload": { + "method": "action_post", + # Optional, see the function definition for required parameters + "parameter": {}, + } + } + } + } + """ + _logger.info(f"[{model}].call_function(), input: {vals}") + data_dict = vals.get("payload", {}) + parameter = data_dict.get("parameter", {}) + + rec = self._search_object(model, vals) + res = getattr(rec, data_dict["method"])(**dict(parameter) if parameter else {}) + return { + "is_success": True, + "result": res, + "messages": "Function {} called successfully".format(data_dict["method"]), + } diff --git a/usability_webhooks/data/config_parameter.xml b/usability_webhooks/data/config_parameter.xml new file mode 100644 index 00000000..c2e199c4 --- /dev/null +++ b/usability_webhooks/data/config_parameter.xml @@ -0,0 +1,35 @@ + + + + webhook.rollback_state_failed + 1 + + + webhook.rollback_except + 1 + + + webhook.create_data_log + True + + + webhook.update_data_log + True + + + webhook.create_update_data_log + True + + + webhook.search_data_log + True + + + webhook.call_function_log + True + + + webhook.ignore_checkcompany_model + [] + + diff --git a/usability_webhooks/data/ir_cron.xml b/usability_webhooks/data/ir_cron.xml new file mode 100644 index 00000000..b4844e5d --- /dev/null +++ b/usability_webhooks/data/ir_cron.xml @@ -0,0 +1,12 @@ + + + + API: Auto-vacuum API logs + + code + model.autovacuum(30, chunk_size=10000) + 1 + days + + + diff --git a/usability_webhooks/models/__init__.py b/usability_webhooks/models/__init__.py new file mode 100644 index 00000000..b0bf4687 --- /dev/null +++ b/usability_webhooks/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import api_log diff --git a/usability_webhooks/models/api_log.py b/usability_webhooks/models/api_log.py new file mode 100644 index 00000000..37a185f1 --- /dev/null +++ b/usability_webhooks/models/api_log.py @@ -0,0 +1,91 @@ +# Copyright 2023 Ecosoft Co., Ltd. (https://ecosoft.co.th) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +import json +import logging +from datetime import datetime, timedelta + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class APILog(models.Model): + _name = "api.log" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "API Logs" + _rec_name = "id" + _order = "id desc" + + data = fields.Text(tracking=True) + model = fields.Char(tracking=True) + route = fields.Char(tracking=True) + function_name = fields.Char(tracking=True) + result = fields.Text(tracking=True) + log_type = fields.Selection( + selection=[ + ("send", "Send"), + ("receive", "Receive"), + ], + default="receive", + ) + state = fields.Selection( + selection=[ + ("draft", "Draft"), + ("done", "Done"), + ("failed", "Failed"), + ], + default="draft", + tracking=True, + ) + + def action_call_api(self): + try: + func = getattr(self.env["webhook.utils"], self.function_name) + res = func(self.model, json.loads(self.data)) + state = "done" if res["is_success"] else "failed" + self.write({"result": res, "state": state}) + except Exception as e: + res = { + "is_success": False, + "messages": e, + } + self.write({"result": res, "state": "failed"}) + return True + + @api.model + def autovacuum(self, days, chunk_size=None): + """Delete all logs older than ``days`` + Called from a cron. + """ + days = (days > 0) and int(days) or 0 + deadline = datetime.now() - timedelta(days=days) + domain = [("create_date", "<=", fields.Datetime.to_string(deadline))] + + # Count the number of records to be deleted + nb_records = self.env["api.log"].search_count(domain) + + if chunk_size: + # Use direct SQL query for deletion with limit + query = """ + DELETE FROM api_log + WHERE id IN ( + SELECT id FROM api_log + WHERE create_date <= %s + ORDER BY create_date ASC + LIMIT %s + ) + """ + self.env.cr.execute( + query, (fields.Datetime.to_string(deadline), chunk_size) + ) + else: + # Use direct SQL query for deletion + query = """ + DELETE FROM api_log + WHERE create_date <= %s + """ + self.env.cr.execute(query, (fields.Datetime.to_string(deadline),)) + + _logger.info("AUTOVACUUM - %s 'api.log' records deleted", nb_records) + return True diff --git a/usability_webhooks/pyproject.toml b/usability_webhooks/pyproject.toml new file mode 100644 index 00000000..4231d0cc --- /dev/null +++ b/usability_webhooks/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/usability_webhooks/readme/CONTRIBUTORS.rst b/usability_webhooks/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..9cf80039 --- /dev/null +++ b/usability_webhooks/readme/CONTRIBUTORS.rst @@ -0,0 +1,2 @@ +* Kitti Upariphutthiphong +* Saran Lim. diff --git a/usability_webhooks/readme/DESCRIPTION.rst b/usability_webhooks/readme/DESCRIPTION.rst new file mode 100644 index 00000000..a7e47a77 --- /dev/null +++ b/usability_webhooks/readme/DESCRIPTION.rst @@ -0,0 +1,7 @@ +This module is base webhooks standard and keep all log that interface + +Step to see logs: + +1. Go to Settings > Technical > API Configuration > API Logs +2. this table will keep all log that interface '/api/create_data' or '/api/create_update_data' +3. Users can used this table for test API by click 'Update API' diff --git a/usability_webhooks/readme/USAGE.rst b/usability_webhooks/readme/USAGE.rst new file mode 100644 index 00000000..a5bebd0e --- /dev/null +++ b/usability_webhooks/readme/USAGE.rst @@ -0,0 +1,149 @@ +Before sending a REST API request to Odoo, an initial call to authenticate the API is necessary. +You can achieve this by calling the ``/web/session/authenticate`` route. + +The authentication format requires a header with ``Content-type`` set to ``application/json``, +and the body should include: + +.. code-block:: python + + { + "jsonrpc": "2.0", + "method": "call", + "params": { + "db": "", + "login": "", + "password": "" + } + } + +Following successful authentication, you can proceed with five API routes: + +1. ``/api/create_data``: This route allows the creation of new data only. + The format for creating data should be in the following structure: + + .. code-block:: python + + { + "params": { + "model": "", + "vals": { + "payload": { + "field1": "value1", + ... + }, + "result_field": ["field1", ...] # optional + } + } + } + +2. ``/api/create_update_data``: This route facilitates updating data. + If the data does not exist, it will automatically create it. + The format follows that of ``create_data``, but it requires a unique key in the field to update the values. + + .. code-block:: python + + { + "params": { + "model": "", + "vals": { + "search_key": { + "": "value", # can be ID or name search string + }, + "payload": { + "field1": "value1", + ... + }, + "result_field": ["field1", ...] # optional + } + } + } + +3. ``/api/update_data``: This route allows updating existing data, + using a unique key in the field to find the desired data and update values in that recordset. + + .. code-block:: python + + { + "params": { + "model": "", + "vals": { + "search_key": { + "": "value", # can be ID or name search string + }, + "payload": { + "field1": "value1", + ... + } + } + } + } + +4. ``/api/search_data``: This route allows you to search for the value of a desired field in a model + by using a search domain to find the desired recordset. You can also limit and order the resulting data. + + .. code-block:: python + + { + "params": { + "model": "", + "vals": { + "payload": { + "search_field": ["field1", "field2", "field3{subfield1, subfield2}", ...], + "search_domain": "[('field', 'operator', 'value')]", + "limit": 1, + "order": "field1 , field2 desc, ..." + } + } + } + } + +5. ``/api/call_function``: This route allows you to call a function on a model object based on the provided input. + + **Parameters**: + - **name** (*str*): The name of the model to perform the function on. + - **method** (*str*): The name of the function to call. + - **parameter** (*dict*): A dictionary containing the arguments to pass to the function (if any). + + .. code-block:: python + + { + "params": { + "model": "", + "vals": { + "search_key": { + "": "value", # can be ID or name search string + }, + "payload": { + "method": "", + "parameter": {"": "", ...} + } + } + } + } + +**Note**: +If you want to attach a file to a record, you can add the key "attachment_ids" at any level of the payload. + + **Example Request with Attachment**: + + .. code-block:: python + + { + "params": { + "model": "", + "vals": { + "search_key": { + "": "value", # can be ID or name search string + }, + "payload": { + "attachment_ids": [ + { + "name": "", + "datas": "" + } + ], + ... + } + } + } + } \ No newline at end of file diff --git a/usability_webhooks/security/ir.model.access.csv b/usability_webhooks/security/ir.model.access.csv new file mode 100644 index 00000000..0196c156 --- /dev/null +++ b/usability_webhooks/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_api_log,access_api_log,model_api_log,base.group_user,1,1,1,1 diff --git a/usability_webhooks/static/description/index.html b/usability_webhooks/static/description/index.html new file mode 100644 index 00000000..37830731 --- /dev/null +++ b/usability_webhooks/static/description/index.html @@ -0,0 +1,573 @@ + + + + + +REST API for Webhook + + + +
+

REST API for Webhook

+ + +

Beta License: AGPL-3 ecosoft-odoo/ecosoft-addons

+

This module is base webhooks standard and keep all log that interface

+

Step to see logs:

+
    +
  1. Go to Settings > Technical > API Configuration > API Logs
  2. +
  3. this table will keep all log that interface ‘/api/create_data’ or ‘/api/create_update_data’
  4. +
  5. Users can used this table for test API by click ‘Update API’
  6. +
+

Table of contents

+ +
+

Usage

+

Before sending a REST API request to Odoo, an initial call to authenticate the API is necessary. +You can achieve this by calling the /web/session/authenticate route.

+

The authentication format requires a header with Content-type set to application/json, +and the body should include:

+
+{
+   "jsonrpc": "2.0",
+   "method": "call",
+   "params": {
+      "db": "<db_name>",
+      "login": "<username>",
+      "password": "<password>"
+   }
+}
+
+

Following successful authentication, you can proceed with five API routes:

+
    +
  1. /api/create_data: This route allows the creation of new data only. +The format for creating data should be in the following structure:

    +
    +{
    +   "params": {
    +      "model": "<model name>",
    +      "vals": {
    +         "payload": {
    +            "field1": "value1",
    +            ...
    +         },
    +         "result_field": ["field1", ...]  # optional
    +      }
    +   }
    +}
    +
    +
  2. +
  3. /api/create_update_data: This route facilitates updating data. +If the data does not exist, it will automatically create it. +The format follows that of create_data, but it requires a unique key in the field to update the values.

    +
    +{
    +   "params": {
    +      "model": "<model name>",
    +      "vals": {
    +         "search_key": {
    +            "<key_field>": "value",  # can be ID or name search string
    +         },
    +         "payload": {
    +            "field1": "value1",
    +            ...
    +         },
    +         "result_field": ["field1", ...]  # optional
    +      }
    +   }
    +}
    +
    +
  4. +
  5. /api/update_data: This route allows updating existing data, +using a unique key in the field to find the desired data and update values in that recordset.

    +
    +{
    +   "params": {
    +      "model": "<model name>",
    +      "vals": {
    +         "search_key": {
    +            "<key_field>": "value",  # can be ID or name search string
    +         },
    +         "payload": {
    +            "field1": "value1",
    +            ...
    +         }
    +      }
    +   }
    +}
    +
    +
  6. +
  7. /api/search_data: This route allows you to search for the value of a desired field in a model +by using a search domain to find the desired recordset. You can also limit and order the resulting data.

    +
    +{
    +   "params": {
    +      "model": "<model name>",
    +      "vals": {
    +         "payload": {
    +            "search_field": ["field1", "field2", "field3{subfield1, subfield2}", ...],
    +            "search_domain": "[('field', 'operator', 'value')]",
    +            "limit": 1,
    +            "order": "field1 , field2 desc, ..."
    +         }
    +      }
    +   }
    +}
    +
    +
  8. +
  9. /api/call_function: This route allows you to call a function on a model object based on the provided input.

    +
    +
    Parameters:
    +
      +
    • name (str): The name of the model to perform the function on.
    • +
    • method (str): The name of the function to call.
    • +
    • parameter (dict): A dictionary containing the arguments to pass to the function (if any).
    • +
    +
    +
    +
    +{
    +   "params": {
    +      "model": "<model name>",
    +      "vals": {
    +         "search_key": {
    +            "<key_field>": "value",  # can be ID or name search string
    +         },
    +         "payload": {
    +            "method": "<method>",
    +            "parameter": {"<key>": "<value>", ...}
    +         }
    +      }
    +   }
    +}
    +
    +
  10. +
+

Note: +If you want to attach a file to a record, you can add the key “attachment_ids” at any level of the payload.

+
+

Example Request with Attachment:

+
+{
+   "params": {
+      "model": "<model name>",
+      "vals": {
+         "search_key": {
+            "<key_field>": "value",  # can be ID or name search string
+         },
+         "payload": {
+            "attachment_ids": [
+               {
+                  "name": "<file_name>",
+                  "datas": "<base64_encoded_data>"
+               }
+            ],
+            ...
+         }
+      }
+   }
+}
+
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Ecosoft
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the ecosoft-odoo/ecosoft-addons project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/usability_webhooks/tests/__init__.py b/usability_webhooks/tests/__init__.py new file mode 100644 index 00000000..87edab45 --- /dev/null +++ b/usability_webhooks/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import test_webhook_utils diff --git a/usability_webhooks/tests/test_webhook_utils.py b/usability_webhooks/tests/test_webhook_utils.py new file mode 100644 index 00000000..618eb0e6 --- /dev/null +++ b/usability_webhooks/tests/test_webhook_utils.py @@ -0,0 +1,131 @@ +from odoo.exceptions import ValidationError +from odoo.tests.common import TransactionCase + + +class TestWebhookUtils(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Initialize test data + cls.webhook_utils = cls.env["webhook.utils"] + cls.partner_model = "res.partner" + + # Create test partner + cls.test_partner = cls.env.ref("base.res_partner_3") + cls.test_partner.write( + { + "name": "Test Partner", + "email": "test@example.com", + } + ) + + def test_01_create_data(self): + """Test creating a new partner via webhook""" + vals = { + "payload": { + "name": "New Partner", + "email": "new@example.com", + } + } + result = self.webhook_utils.create_data(self.partner_model, vals) + self.assertTrue(result["is_success"]) + self.assertTrue(result["result"]["id"]) + + # Verify created partner + partner = self.env[self.partner_model].browse(result["result"]["id"]) + self.assertEqual(partner.name, "New Partner") + self.assertEqual(partner.email, "new@example.com") + + def test_02_update_data(self): + """Test updating existing partner via webhook""" + vals = { + "search_key": { + "id": self.test_partner.id, + }, + "payload": { + "name": "Updated Partner", + "email": "updated@example.com", + }, + } + result = self.webhook_utils.update_data(self.partner_model, vals) + self.assertTrue(result["is_success"]) + + # Verify updated partner + self.assertEqual(self.test_partner.name, "Updated Partner") + self.assertEqual(self.test_partner.email, "updated@example.com") + + def test_03_search_data(self): + """Test searching partners via webhook""" + vals = { + "payload": { + "search_field": ["name", "email"], + "search_domain": "[('name', '=', 'Test Partner')]", + "limit": 1, + } + } + result = self.webhook_utils.search_data(self.partner_model, vals) + self.assertTrue(result["is_success"]) + self.assertTrue(result["result"]) + self.assertEqual(result["result"][0]["name"], "Test Partner") + + def test_04_create_with_many2one(self): + """Test creating record with many2one relation""" + vals = { + "payload": { + "name": "New Company", + "country_id": "Thailand", # Using name instead of ID + }, + "auto_create": {"country_id": {"name": "Thailand"}}, + } + result = self.webhook_utils.create_data("res.company", vals) + self.assertTrue(result["is_success"]) + + def test_05_create_with_attachment(self): + """Test creating record with attachment""" + vals = { + "payload": { + "name": "With Attachment", + "attachment_ids": [ + { + "name": "test.txt", + "datas": "SGVsbG8gV29ybGQ=", # Base64 encoded "Hello World" + } + ], + } + } + result = self.webhook_utils.create_data(self.partner_model, vals) + self.assertTrue(result["is_success"]) + + # Verify attachment + attachment = self.env["ir.attachment"].search( + [ + ("res_model", "=", self.partner_model), + ("res_id", "=", result["result"]["id"]), + ] + ) + self.assertTrue(attachment) + self.assertEqual(attachment.name, "test.txt") + + def test_06_call_function(self): + """Test calling model function via webhook""" + vals = { + "search_key": { + "id": self.test_partner.id, + }, + "payload": { + "method": "_compute_display_name", + "parameter": {}, + }, + } + result = self.webhook_utils.call_function(self.partner_model, vals) + self.assertTrue(result["is_success"]) + + def test_07_invalid_search_key(self): + """Test error handling for invalid search key""" + vals = { + "payload": { + "name": "Test", + } + } + with self.assertRaises(ValidationError): + self.webhook_utils.update_data(self.partner_model, vals) diff --git a/usability_webhooks/views/api_log.xml b/usability_webhooks/views/api_log.xml new file mode 100644 index 00000000..197e9f5c --- /dev/null +++ b/usability_webhooks/views/api_log.xml @@ -0,0 +1,136 @@ + + + + view.api.log.list + api.log + + + + + + + + + + + + + view.api.log.form + api.log + +
+
+
+ + + + + + + + + + + + + +
+ +
+
+ +
+ +
+
+
+ + +
+
+ + view.api.log.search + api.log + + + + + + + + + + + + + + + + + + + API Logs + api.log + list,form + + + + + + +