diff --git a/base_api/README.rst b/base_api/README.rst new file mode 100644 index 00000000..68a33df6 --- /dev/null +++ b/base_api/README.rst @@ -0,0 +1,31 @@ +.. image:: https://itpp.dev/images/infinity-readme.png + :alt: Tested and maintained by IT Projects Labs + :target: https://itpp.dev + +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: https://www.gnu.org/licenses/lgpl + :alt: License: LGPL-3 + +========== + Base Api +========== + +Module extends *base*-model in purpose of creating and quering objects. + +Questions? +========== + +To get an assistance on this module contact us by email :arrow_right: help@itpp.dev + +Further information +=================== + +Odoo Apps Store: https://apps.odoo.com/apps/modules/15.0/base_api/ + + +Notifications on updates: `via Atom +`__, +`by Email +`__ + +Tested on `Odoo 15.0 `_ diff --git a/base_api/__init__.py b/base_api/__init__.py new file mode 100644 index 00000000..b29fc4cc --- /dev/null +++ b/base_api/__init__.py @@ -0,0 +1,4 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from . import models +from . import lib diff --git a/base_api/__manifest__.py b/base_api/__manifest__.py new file mode 100644 index 00000000..5847d078 --- /dev/null +++ b/base_api/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2019 Anvar Kildebekov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + "name": """Base API""", + "summary": """Basic function and methods of API for openapi or XML-RPC""", + "category": "Hidden", + "version": "1.0.1", + "application": False, + "author": "IT-Projects LLC, Anvar Kildebekov", + "support": "apps@itpp.dev", + "website": "https://apps.odoo.com/apps/modules/13.0/base_api/", + "license": "LGPL-3", + "depends": [], + "external_dependencies": {"python": [], "bin": []}, + "data": [], + "demo": [], + "qweb": [], + "post_load": None, + "pre_init_hook": None, + "post_init_hook": None, + "uninstall_hook": None, + "auto_install": False, + "installable": True, +} diff --git a/base_api/doc/changelog.rst b/base_api/doc/changelog.rst new file mode 100644 index 00000000..6e6b4720 --- /dev/null +++ b/base_api/doc/changelog.rst @@ -0,0 +1,8 @@ +`1.0.1` +------- +- **Improvement:** Compatibility with python 3.9 + +`1.0.0` +------- + +- **Init version** diff --git a/base_api/doc/index.rst b/base_api/doc/index.rst new file mode 100644 index 00000000..2af1d159 --- /dev/null +++ b/base_api/doc/index.rst @@ -0,0 +1,317 @@ +========== + Base Api +========== + +Usage +===== + +*Methods intended for calling via API (e.g. OpenAPI or RPC)*: + +search_or_create +---------------- + +*search_or_create(self, vals, active\_test=True)* + +*– Purpose*: + - To resolve “race conditions”, comparing to separated searching + and creation, that can lead to record-duplication. + +*– Input data*: + - `vals`-variable: + - fields-values as for *create*-method + - type of dictionary (not nested) + - e.g. + .. code-block:: + + vals = { + 'name': 'John', + 'Age': 25 + } + + - `active_test`-variable (for models that have field named `active`): + - flag to search only for *active* records + - type of *boolean* + - e.g. + .. code-block:: + +  active_test = False` # to also search in *in-active* records + +*– Notes*: + - *many2one* fields in `vals`: + - type of integer + - e.g. + .. code-block:: + + vals = { + 'company_id': 1 + } + + - *x2many* fields in `vals`: + - ignored for searching + - type of list of *tuples* + - e.g. + .. code-block:: + + vals = { + 'name': John', + 'children_ids': [(4,4,0), + (4,5,0)] + } + - For more information look `here `__ + +*– Example*: + +.. code-block:: + + -> # Searching for existing record + + -> vals = {'company_id': 1 } + + -> res_partner_object.search_or_create(vals) + + (False, [22, 1, 8, 7, 3]) + + -> # Creating record (with many2one field) + + -> vals = { 'name': 'John Doe Neo', 'company_id': 1 } + + -> res_partner_object.search_or_create(vals) + + (True, [78]) + + -> # Creating record (for x2many-fields) + + -> vals = { 'name': 'Albert Bubis', 'child_ids': [(4, 11, 0), (4, 5, 0)] } + + -> res_partner_object.search_or_create(vals) + + (True, [79]) + +*– Algorithm*: + +1. Creates *domain* out of `vals` for searching + +2. Searches for records satisfiy *domain* constraints (`is_new = False`) + +3. If no record was found: + - then it creates one with `vals` + - sets *True* to `is_new` + +4. Returns two variables: + - `is_new` - *boolean*: Shows if record was created or not + - `ids` - list of records, that were found, or id of created + one + +search_read_nested +------------------ + +*search_read_nested(self, domain=None, fields=None, offset=0, limit=None, order=None, delimeter='/')* + +*– Purpose*: + - Simplifies reading data to one request; + - Comparing to default **search\_read**: + - ``fields`` can be nested, so the method will return list of + record-dictionaries with nested fields from related models (via + *x2many*, *many2one*). Nested fields are specified as slash-separated + sequence, for example: + .. code-block:: + + fields = [ + 'company_id/id', + 'company_id/name' + ] + +*– Input data*: + - `domain`-variable: + - list of statements for searching, as for usual + *search*-method + - type of list of *tuples* + - e.g.  + .. code-block:: + + domain = [ + ('name', '=', 'John'), + ('Age','>','10') + ] + + - `fields`-variable: + - fields to be read from founded records (including nested + fields via dot-notation) + - list of *strings* + - e.g. if ``author_id``, ``edition_ids`` are many2one and many2many + fields, then the variable can be specified as following: + .. code-block:: + + fields = [ + 'book_name', + 'author_id/id', + 'author_id/name', + `edition_ids/id`, + `edition_ids/year` + ] + + - `offset`-variable: + - number of records to ignore + - type of *integer* + - e.g. ``offset = 2`` # will ignore two first-founded records + - `limit`-variable: + - number of founded records to show + - type of + - e.g. ``limit = 3`` # will show three first-founded records + - `order`-variable: + - criteria of sorting founded records + - type of *string* + - e.g. ``order = 'name desc'`` # will sort records descending by ‘name’ + - `count`-variable: + - flag to return number of founded records, instead of records + itself + - type of *boolean* + - e.g. ``count = True`` + - `delimeter`-variable: + - char that divide nesting in field + - type of *char* + - e.g. ``company_id/country_id/name # delimeter='/' + +*– Notes*: + - for *many2one* fields the method returns a dictionary with + nested fields + - for *x2many* fields the method returns list of + record-dictionaries with nested fields + +*– Example* + +.. code-block:: + + -> search_domain = [('company_id.category', '=', 'Supermarket')] + + -> show_fields = [ 'name', 'company_id/id', 'company_id/name', 'company_id/website', 'country_id/id', 'child_ids/name', 'child_ids/id' ] + + -> res_partner_object.search_read_nested(domain=search_domain, fields=show_fields, '.') + + [ + { + + 'name': 'Partner #1', + + 'company_id': { + 'id': 1, + 'name': 'Supermarket for me', + 'website': 'http://superfood.com' + }, + + 'country_id': { 'id':102' }, + + 'child_ids': [ + { + 'id': 1, + 'name': Child #1, + }, { + 'id': 2, + 'name': Child #2, + } + ] + + }, + + ..., + + { + + 'name': 'Partner #37', + + 'company_id': { + 'id': 25, + 'name': 'Supermarket in Eternity', + 'website': 'http://giantbroccoly.com' + }, + + 'country_id': { 'id': 103 } + + 'child_ids': [] + + } + ] + + +*– Algorithm*: + 1. Searches for records that satisfy `domain` + + 2. Returns list of dictionaries with fields specified in `fields` + +create_or_update_by_external_id +------------------------------- + +*create_or_update_by_external_id(self, vals)* + +*– Purpose*: + - work with model (create or update values) by custom (external) + identification + +*– Input data*: + - `vals`-variable: + - type of *dictionary* as for *create*-method + - Must contain `id` field type of *string* + - e.g. + .. code-block:: + +  vals = { + 'id': 'ext.id_1', + 'name: 'John', + 'age': 37, + 'job_id': 'ext.work_1', + 'child_ids' : [ + (4, 'ext.child_1', 0), + (4, 'ext.child_2', 0) + ] + }` + +*– Notes*: + - for *x2x*-fields `id` might be *string* (external id) + + - for *x2many*-fields use *tuples* `this `__, + + - If `id` of *x2x* fields are not found, it will return error + (*raise Exception*). In order to avoid this, call the function + for the models of this fields + + - Work of function based on *External Identifiers* (**ir.model.data** ) + +*– Example* + +.. code-block:: + + -> # Create non-existed record + + -> vals = { + 'id': 'ext.id_5', + 'name': 'John', + 'customer_id': 'ext.id_3', + 'child_ids': [(4, 'ext.id_child_5, 0), (4, 5, 0)] + } + + -> sale_order_object.create_or_update_by_external_id(vals) + + (True, 38) + + -> # Update existing record + + -> vals = { + 'id': 'ext.id_5', + 'customer_id': 'ext.id_5', + 'child_ids': [(4, 'ext.id_child_4', 0)] + } + + -> sale_order_object.create_or_update_by_external_id(vals) + + (False, 38) + +*– Algorithm*: + - Searches for record by its external id (`id` in `vals`) through + *self.env.ref*-function + - If no record was found: + - then it creates one with requested values (`vals`) + - register `id` of `vals` in **ir.model.data** + - sets *True* to `is_new` + - Returns two variables: + - `is_new` - *True* or *False*: if record was created or not + - `id` (inner) of updated or created record diff --git a/base_api/i18n/fr.po b/base_api/i18n/fr.po new file mode 100644 index 00000000..9c7dc5d0 --- /dev/null +++ b/base_api/i18n/fr.po @@ -0,0 +1,28 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_api +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-09-15 18:11+0000\n" +"PO-Revision-Date: 2022-09-15 20:28+0200\n" +"Last-Translator: \n" +"Language-Team: Alpis Traduction et Interprétation \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: fr\n" +"X-Generator: Poedit 2.0.4\n" + +#. module: base_api +#: model:ir.model,name:base_api.model_base +msgid "Base" +msgstr "Base" + +#. module: base_api +#: code:addons/base_api/lib/pinguin.py:0 +#, python-format +msgid "The model \"%s\" has no such field: \"%s\"." +msgstr "Le modèle \"%s\" n'a pas ce champ : \"%s\"." diff --git a/base_api/lib/__init__.py b/base_api/lib/__init__.py new file mode 100644 index 00000000..247b1360 --- /dev/null +++ b/base_api/lib/__init__.py @@ -0,0 +1 @@ +from . import pinguin diff --git a/base_api/lib/pinguin.py b/base_api/lib/pinguin.py new file mode 100644 index 00000000..3dacbb54 --- /dev/null +++ b/base_api/lib/pinguin.py @@ -0,0 +1,355 @@ +# Copyright 2018, XOE Solutions +# Copyright 2018-2019 Rafis Bikbov +# Copyright 2019 Yan Chirino +# Copyright 2019-2020 Anvar Kildebekov +# Copyright 2020 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +# pyling: disable=redefined-builtin + + +import collections +import collections.abc +import datetime + +import six +import werkzeug.wrappers +from psycopg2.extensions import ISOLATION_LEVEL_READ_COMMITTED + +import odoo +from odoo.http import request + +try: + import simplejson as json +except ImportError: + import json + + +# 4xx Client Errors +CODE__obj_not_found = ( + 404, + "Object not found", + "This object is not available on this instance.", +) +# 5xx Server errors +CODE__invalid_spec = ( + 501, + "Invalid Field Spec", + "The field spec supplied is not valid.", +) + + +def error_response(status, error, error_descrip): + """Error responses wrapper. + :param int status: The error code. + :param str error: The error summary. + :param str error_descrip: The error description. + :returns: The werkzeug `response object`_. + :rtype: werkzeug.wrappers.Response + .. _response object: + http://werkzeug.pocoo.org/docs/0.14/wrappers/#module-werkzeug.wrappers + """ + return werkzeug.wrappers.Response( + status=status, + content_type="application/json; charset=utf-8", + response=json.dumps({"error": error, "error_descrip": error_descrip}), + ) + + +def validate_extra_field(field): + """Validates extra fields on the fly. + :param str field: The name of the field. + :returns: None, if validated, otherwise raises. + :rtype: None + :raise: werkzeug.exceptions.HTTPException if field is invalid. + """ + if not isinstance(field, str): + return werkzeug.exceptions.HTTPException( + response=error_response(*CODE__invalid_spec) + ) + + +def validate_spec(model, spec): + """Validates a spec for a given model. + :param object model: (:obj:`Model`) The model against which to validate. + :param list spec: The spec to validate. + :returns: None, if validated, otherwise raises. + :rtype: None + :raise: Exception: + * if the tuple representing the field does not have length 2. + * if the second part of the tuple representing the field is not a list or tuple. + * if if a tuple representing a field consists of two parts, but the first part is not a relative field. + * if if the second part of the tuple representing the field is of type tuple, but the field is the ratio 2many. + * if if the field is neither a string nor a tuple. + """ + self = model + for field in spec: + if isinstance(field, tuple): + # Syntax checks + if len(field) != 2: + raise Exception( + "Tuples representing fields must have length 2. (%r)" % field + ) + if not isinstance(field[1], (tuple, list)): + raise Exception( + """Tuples representing fields must have a tuple wrapped in + a list or a bare tuple as it's second item. (%r)""" + % field + ) + # Validity checks + fld = self._fields[field[0]] + if not fld.relational: + raise Exception( + "Tuples representing fields can only specify relational fields. (%r)" + % field + ) + if isinstance(field[1], tuple) and fld.type in ["one2many", "many2many"]: + raise Exception( + "Specification of a 2many record cannot be a bare tuple. (%r)" + % field + ) + elif not isinstance(field, six.string_types): + raise Exception( + "Fields are represented by either a strings or tuples. Found: %r" + % type(field) + ) + + +def update(d, u): + """Update value of a nested dictionary of varying depth. + :param dict d: Dictionary to update. + :param dict u: Dictionary with updates. + :returns: Merged dictionary. + :rtype: dict + """ + for k, v in u.items(): + if isinstance(v, collections.abc.Mapping): + d[k] = update(d.get(k, collections.OrderedDict([])), v) + else: + d[k] = v + return d + + +# Transform string fields to dictionary +def transform_strfields_to_dict(fields_list, delim="/"): + """Transform string fields to dictionary. + Example: + for ['name', 'email', 'bank_ids/bank_id/id', 'bank_ids/bank_name', 'bank_ids/id'] + the result will be the next dictionary + { + 'name': None, + 'email': None + 'bank_ids': { + 'bank_name': None, + 'bank_id': { + 'id': None + } + }, + } + :param list fields_list: The list of string fields. + :returns: The dict of transformed fields. + :rtype: dict + """ + dct = {} + for field in fields_list: + parts = field.split(delim) + data = None + for part in parts[::-1]: + if part == ".id": + part = "id" + data = {part: data} + update(dct, data) + return dct + + +def transform_dictfields_to_list_of_tuples(record, dct, ENV=False): + """Transform fields dictionary to list. + for { + 'name': None, + 'email': None + 'bank_ids': { + 'bank_name': None, + 'bank_id': { + 'id': None + } + }, + } + the result will be + ['name', 'email', ('bank_ids', ['bank_name', ('bank_id', ('id',))])] + :param odoo.models.Model record: The model object. + :param dict dct: The dictionary. + :returns: The list of transformed fields. + :rtype: list + """ + fields_with_meta = { + k: meta for k, meta in record.fields_get().items() if k in dct.keys() + } + result = {} + for key, value in dct.items(): + if isinstance(value, dict): + model_obj = get_model_for_read(fields_with_meta[key]["relation"], ENV) + inner_result = transform_dictfields_to_list_of_tuples(model_obj, value, ENV) + is_2many = fields_with_meta[key]["type"].endswith("2many") + result[key] = list(inner_result) if is_2many else tuple(inner_result) + else: + result[key] = value + return [(key, value) if value else key for key, value in result.items()] + + +####################### +# Pinguin ORM Wrapper # +####################### + + +# List of dicts from model +def get_dictlist_from_model(model, spec, **kwargs): + """Fetch dictionary from one record according to spec. + :param str model: The model against which to validate. + :param tuple spec: The spec to validate. + :param dict kwargs: Keyword arguments. + :param list kwargs['domain']: (optional). The domain to filter on. + :param int kwargs['offset']: (optional). The offset of the queried records. + :param int kwargs['limit']: (optional). The limit to query. + :param str kwargs['order']: (optional). The postgres order string. + :param tuple kwargs['include_fields']: (optional). The extra fields. + This parameter is not implemented on higher level code in order + to serve as a soft ACL implementation on top of the framework's + own ACL. + :param tuple kwargs['exclude_fields']: (optional). The excluded fields. + :param char kwargs['delimeter']: delimeter of nested fields. + :param object kwargs['env']: Model's environment. + :returns: The list of python dictionaries of the requested values. + :rtype: list + """ + domain = kwargs.get("domain", []) + offset = kwargs.get("offset", 0) + limit = kwargs.get("limit") + order = kwargs.get("order") + include_fields = kwargs.get( + "include_fields", () + ) # Not actually implemented on higher level (ACL!) + exclude_fields = kwargs.get("exclude_fields", ()) + delim = kwargs.get("delimeter", "/") + ENV = kwargs.get("env", False) + + model_obj = get_model_for_read(model, ENV) + + records = model_obj.sudo().search(domain, offset=offset, limit=limit, order=order) + + # Do some optimization for subfields + _prefetch = {} + for field in spec: + if isinstance(field, str): + continue + _fld = records._fields[field[0]] + if _fld.relational: + _prefetch[_fld.comodel] = records.mapped(field[0]).ids + + for mod, ids in _prefetch.items(): + get_model_for_read(mod, ENV).browse(ids).read() + + result = [] + for record in records: + result += [ + get_dict_from_record( + record, spec, include_fields, exclude_fields, ENV, delim + ) + ] + + return result + + +# Get a model with special context +def get_model_for_read(model, ENV=False): + """Fetch a model object from the environment optimized for read. + Postgres serialization levels are changed to allow parallel read queries. + To increase the overall efficiency, as it is unlikely this API will be used + as a mass transactional interface. Rather we assume sequential and structured + integration workflows. + :param str model: The model to retrieve from the environment. + :param object env: Environment + :returns: the framework model if exist, otherwise raises. + :rtype: odoo.models.Model + :raise: werkzeug.exceptions.HTTPException if the model not found in env. + """ + if ENV: + return ENV[model] + cr, uid = request.cr, request.session.uid + test_mode = request.registry.test_cr + if not test_mode: + # Permit parallel query execution on read + # Contrary to ISOLATION_LEVEL_SERIALIZABLE as per Odoo Standard + cr._cnx.set_isolation_level(ISOLATION_LEVEL_READ_COMMITTED) + try: + return request.env(cr, uid)[model] + except KeyError as e: + err = list(CODE__obj_not_found) + err[2] = 'The "%s" model is not available on this instance.' % model + raise werkzeug.exceptions.HTTPException(response=error_response(*err)) from e + + +# Python > 3.5 +# def get_dict_from_record(record, spec: tuple, include_fields: tuple, exclude_fields: tuple): + +# Extract nested values from a record +def get_dict_from_record( + record, spec, include_fields, exclude_fields, ENV=False, delim="/" +): + """Generates nested python dict representing one record. + Going down to the record level, as the framework does not support nested + data queries natively as they are typical for a REST API. + :param odoo.models.Model record: The singleton record to load. + :param tuple spec: The field spec to load. + :param tuple include_fields: The extra fields. + :param tuple exclude_fields: The excluded fields. + :returns: The python dictionary representing the record according to the field spec. + :rtype collections.OrderedDict + """ + map(validate_extra_field, include_fields + exclude_fields) + result = collections.OrderedDict([]) + _spec = [fld for fld in spec if fld not in exclude_fields] + list(include_fields) + if list(filter(lambda x: isinstance(x, six.string_types) and delim in x, _spec)): + _spec = transform_dictfields_to_list_of_tuples( + record, transform_strfields_to_dict(_spec, delim), ENV + ) + validate_spec(record, _spec) + + for field in _spec: + + if isinstance(field, tuple): + # It's a 2many (or a 2one specified as a list) + if isinstance(field[1], list): + result[field[0]] = [] + for rec in record[field[0]]: + result[field[0]] += [ + get_dict_from_record(rec, field[1], (), (), ENV, delim) + ] + # It's a 2one + if isinstance(field[1], tuple): + result[field[0]] = get_dict_from_record( + record[field[0]], field[1], (), (), ENV, delim + ) + # Normal field, or unspecified relational + elif isinstance(field, six.string_types): + if not hasattr(record, field): + raise odoo.exceptions.ValidationError( + odoo._('The model "%s" has no such field: "%s".') + % (record._name, field) + ) + + # result[field] = getattr(record, field) + if isinstance(record[field], datetime.date): + value = record[field].strftime("%Y-%m-%d %H:%M:%S") + else: + value = record[field] + + result[field] = value + fld = record._fields[field] + if fld.relational: + if fld.type.endswith("2one"): + result[field] = value.id + elif fld.type.endswith("2many"): + result[field] = value.ids + elif (value is False or value is None) and fld.type != "boolean": + # string field cannot be false in response json + result[field] = "" + return result diff --git a/base_api/models/__init__.py b/base_api/models/__init__.py new file mode 100644 index 00000000..b1711c2b --- /dev/null +++ b/base_api/models/__init__.py @@ -0,0 +1,3 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from . import base diff --git a/base_api/models/base.py b/base_api/models/base.py new file mode 100644 index 00000000..0fbad50e --- /dev/null +++ b/base_api/models/base.py @@ -0,0 +1,105 @@ +# Copyright 2019,2022 Ivan Yelizariev +# Copyright 2019 Anvar Kildebekov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import api, models + +from ..lib import pinguin + +PREFIX = "__base_api__" + + +class Base(models.AbstractModel): + _inherit = "base" + + @api.model + def search_or_create(self, vals, active_test=True): + domain = [ + (k, "=", v) + for k, v in vals.items() + if not self._fields.get(k).type.endswith("2many") + ] + records = self.with_context(active_test=active_test).search(domain) + is_new = False + if not records: + is_new = True + records = self.create(vals) + return (is_new, records.ids) + + @api.model + def search_read_nested( + self, domain=None, fields=None, offset=0, limit=None, order=None, delimeter="/" + ): + result = pinguin.get_dictlist_from_model( + self._name, + tuple(fields), + domain=domain, + offset=offset, + limit=limit, + order=order, + env=self.env, + delimeter=delimeter, + ) + return result + + @api.model + def create_or_update_by_external_id(self, vals): + ext_id = vals.get("id") + is_new = False + imd_env = self.env["ir.model.data"] + # if external id not defined + if not isinstance(ext_id, str): + raise ValueError('"id" field must be type of "string"') + # if x2x fields values are exist + fields_2many = [] + + def convert_external_2_inner_id(ext_id, field): + try: + result = imd_env._xmlid_lookup(PREFIX + "." + ext_id)[1] + except ValueError as e: + raise ValueError( + "No object with external id in field {}: {}".format(field, ext_id) + ) from e + return result + + for field in vals: + # for many2one fields + if self._fields[field].type == "many2one" and isinstance(vals[field], str): + vals[field] = convert_external_2_inner_id(vals.get(field), field) + elif self._fields[field].type.endswith("2many"): + fields_2many.append(field) + + # for x2many fields + for field in fields_2many: + for index, tuple_record in enumerate(vals[field]): + list_record = list(tuple_record) + if list_record[0] in [1, 2, 3, 4] and isinstance(list_record[1], str): + list_record[1] = convert_external_2_inner_id(list_record[1], field) + elif list_record[0] == 6: + for record_for_replace in list_record[2]: + if isinstance(record_for_replace, str): + record_for_replace = convert_external_2_inner_id( + record_for_replace, field + ) + vals[field][index] = tuple(list_record) + + # If external id exists... + try: + inner_id = imd_env._xmlid_lookup(PREFIX + "." + ext_id)[1] + # No: Create record and register external_key + except ValueError: + is_new = True + inner_id = self.create(vals).id + imd_env.create( + { + "name": vals.get("id"), + "model": self._name, + "module": PREFIX, + "res_id": inner_id, + } + ) + else: + # Yes: Write changes to record + self.browse(inner_id).write(vals) + + return (is_new, inner_id) diff --git a/base_api/static/description/icon.png b/base_api/static/description/icon.png new file mode 100644 index 00000000..b43a0a13 Binary files /dev/null and b/base_api/static/description/icon.png differ diff --git a/base_api/tests/__init__.py b/base_api/tests/__init__.py new file mode 100644 index 00000000..3f75ad85 --- /dev/null +++ b/base_api/tests/__init__.py @@ -0,0 +1,3 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +from . import test_base diff --git a/base_api/tests/test_base.py b/base_api/tests/test_base.py new file mode 100644 index 00000000..76655e44 --- /dev/null +++ b/base_api/tests/test_base.py @@ -0,0 +1,215 @@ +# Copyright 2019,2022 Ivan Yelizariev +# Copyright 2019 Anvar Kildebekov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +prefix = "__base_api__." + + +@tagged("-at_install", "post_install") +class TestBase(TransactionCase): + def test_search_or_create(self): + # define test variables + partner_obj = self.env["res.partner"] + company_obj = self.env["res.company"] + t_name = "test_search_or_create" + # + # Test #1: Record creation + # + t_vals = {"name": t_name} + partner_obj.search([("name", "=", t_name)]).unlink() + is_new, record_ids = partner_obj.search_or_create(t_vals) + record = partner_obj.browse(record_ids) + # (1) record was created + # (2) record have field's value that was requested + self.assertTrue(is_new) + self.assertEqual(record.name, t_name) + # + # Test #2: Record searching + # + is_new, record_ids2 = partner_obj.search_or_create(t_vals) + record = partner_obj.browse(record_ids2) + # (1) record have been founded (not created) + # (2) this the same record as in the Test #1 + self.assertFalse(is_new) + self.assertEqual(record_ids[0], record_ids2[0]) + # + # Test #3: Record creation (x2x fields) + # + t_child_1 = partner_obj.create({"name": "TestChild1"}) + t_child_2 = partner_obj.create({"name": "TestChild2"}) + t_company = company_obj.create({"name": "TestCompany"}) + t_vals = { + "name": "TestParent", + "child_ids": [(4, t_child_1.id, 0), (4, t_child_2.id, 0)], + "company_id": t_company.id, + } + is_new, record_ids3 = partner_obj.search_or_create(t_vals) + record = partner_obj.browse(record_ids3) + # (1) record was created + # (2) record have x2x field's values that were requested? + self.assertTrue(is_new) + self.assertEqual([t_child_1.id, t_child_2.id], record.child_ids.ids) + self.assertEqual(record.company_id.id, t_company.id) + # + # Test #4: Record searching (x2many fields are ignored) + # + is_new, record_ids4 = partner_obj.search_or_create(t_vals) + # (1) record have been founded (not created) + # (2) this is the same record as in the Test #3 + self.assertFalse(is_new) + self.assertEqual(record_ids3[0], record_ids4[0]) + + def test_search_read_nested(self): + # Define test variables + partner_obj = self.env["res.partner"] + country_obj = self.env["res.country"] + company_obj = self.env["res.company"] + category_obj = self.env["res.partner.category"] + t_country_1 = country_obj.create({"name": "TestCountry1", "code": "xxx"}) + t_country_2 = country_obj.create({"name": "TestCountry2", "code": "yyy"}) + t_company_1 = company_obj.create( + {"name": "TestCompany1", "country_id": t_country_1.id} + ) + t_company_2 = company_obj.create( + {"name": "TestCompany2", "country_id": t_country_2.id} + ) + t_category_1 = category_obj.create({"name": "TestCategory1"}) + t_category_2 = category_obj.create({"name": "TestCategory2"}) + t_category_3 = category_obj.create({"name": "TestCategory3"}) + t_category_4 = category_obj.create({"name": "TestCategory4"}) + t_partner_1 = partner_obj.create( + { + "name": "TestPartner1", + "company_id": t_company_1.id, + "category_id": [(4, t_category_1.id, 0), (4, t_category_2.id, 0)], + "street": "TestStreet", + } + ) + t_partner_2 = partner_obj.create( + { + "name": "TestPartner2", + "company_id": t_company_2.id, + "category_id": [(4, t_category_3.id, 0), (4, t_category_4.id, 0)], + "street": "TestStreet", + } + ) + correct_result = [ + { + "name": t_partner_1.name, + "category_id": [ + {"id": t_category_1.id, "name": t_category_1.name}, + {"id": t_category_2.id, "name": t_category_2.name}, + ], + "company_id": { + "id": t_company_1.id, + "name": t_company_1.name, + "country_id": {"id": t_country_1.id, "name": t_country_1.name}, + }, + }, + { + "name": t_partner_2.name, + "category_id": [ + {"id": t_category_3.id, "name": t_category_3.name}, + {"id": t_category_4.id, "name": t_category_4.name}, + ], + "company_id": { + "id": t_company_2.id, + "name": t_company_2.name, + "country_id": {"id": t_country_2.id, "name": t_country_2.name}, + }, + }, + ] + # + # Test 1: Record searching-reading + # + search_domain = [("street", "=", "TestStreet")] + show_fields = [ + "name", + "category_id.id", + "category_id.name", + "company_id.id", + "company_id.name", + "company_id.country_id.name", + "company_id.country_id.id", + ] + delimeter = "." + record_list = partner_obj.search_read_nested( + domain=search_domain, fields=show_fields, delimeter=delimeter + ) + # (1) records has requested values + self.assertEqual(correct_result, record_list) + + def test_create_or_update_by_external_id(self): + partner_obj = self.env["res.partner"] + company_obj = self.env["res.company"] + t_company_ext_id = "ext.company_1" + t_child_1_ext_id = "ext.child_1" + t_child_2_ext_id = "ext.child_2" + # + # Test #0: Check correct creation of external id + # + t_company = company_obj.browse( + company_obj.create_or_update_by_external_id( + {"id": t_company_ext_id, "name": "TestCompany"} + )[1] + ) + t_child_1 = partner_obj.browse( + partner_obj.create_or_update_by_external_id( + {"id": t_child_1_ext_id, "name": "TestChild1"} + )[1] + ) + t_child_2 = partner_obj.browse( + partner_obj.create_or_update_by_external_id( + {"id": t_child_2_ext_id, "name": "TestChild2"} + )[1] + ) + # (1) Check field value (correctness of creation) + # (2) Check field value (correctness of creation) + # (3) Check field value (correctness of creation) + self.assertEqual( + t_company.get_external_id()[t_company.id].split(".", 1)[1], t_company_ext_id + ) + self.assertEqual( + t_child_1.get_external_id()[t_child_1.id].split(".", 1)[1], t_child_1_ext_id + ) + self.assertEqual( + t_child_2.get_external_id()[t_child_2.id].split(".", 1)[1], t_child_2_ext_id + ) + # + # Test #1: Error : "External ID not defined" + # + t_vals = { + "name": "John", + "child_ids": [(4, t_child_1_ext_id, 0), (4, t_child_2_ext_id, 0)], + "company_id": t_company_ext_id, + } + with self.assertRaises(ValueError): + partner_obj.create_or_update_by_external_id(t_vals) + # + # Test #2: Record creation + # + t_vals["id"] = "ext.partner_1" + is_new, record_id2 = partner_obj.create_or_update_by_external_id(t_vals) + record = partner_obj.browse(record_id2) + # (1) record was created + # (2) record have requested external id + # (3) record have one2many-field's value that was requested + # (4) record have many2one-field's value that was requested + self.assertTrue(is_new) + self.assertEqual(record.get_external_id()[record.id], prefix + "ext.partner_1") + self.assertEqual(record.child_ids.ids, [t_child_1.id, t_child_2.id]) + self.assertEqual(record.company_id, t_company) + # + # Test #3: Record update + # + t_vals = {"id": "ext.partner_1", "child_ids": [(3, t_child_2_ext_id, 0)]} + is_new, record_id3 = partner_obj.create_or_update_by_external_id(t_vals) + record = partner_obj.browse(record_id3) + # (1) record was updated + # (2) this is the same record (by id) that was created in Test#1 + # (3) record have one2many field's value that was requested + self.assertFalse(is_new) + self.assertEqual(record_id2, record_id3) + self.assertEqual(record.child_ids.ids, [t_child_1.id]) diff --git a/openapi/README.rst b/openapi/README.rst new file mode 100644 index 00000000..10b339ee --- /dev/null +++ b/openapi/README.rst @@ -0,0 +1,79 @@ +.. image:: https://itpp.dev/images/infinity-readme.png + :alt: Tested and maintained by IT Projects Labs + :target: https://itpp.dev + +.. image:: https://img.shields.io/badge/license-LGPL--3-blue.png + :target: https://www.gnu.org/licenses/lgpl + :alt: License: LGPL-3 + +========================== + REST API/OpenAPI/Swagger +========================== + +Set up REST API and export OpenAPI (Swagger) specification file for integration +with whatever system you need. All can be configured in Odoo UI, no extra module +is needed. + +This module implements a ``/api/v1/`` route tree. + +Authentication +============== + +* Database inference: Database name encoded and appended to the token +* User authentication through the actual token + +As a workaround for multi-db Odoo instances, system uses `Basic Authentication `__ with +``db_name:token`` credentials, where ``token`` is a new field in ``res.users`` +model. That is, whenever you see Username / Password to setup OpenAPI +connection, use Database Name / OpenAPI toekn accordingly. + +Roadmap +======= + +* TODO: Rewrite tests to replace dependency ``mail`` to ``web`` module. +* TODO: Add a button to developer menu to grant access to current model + + * `Activate Developer Mode `__ + * Open the developer tools drop down + * Click menu ``Configure REST API`` located within the dropdown + * On the form that opens, activate and configure this module for REST API accessability. + * Click ``[Apply]`` + +* TODO: when user is not authenticated api returns 200 with the message below, instead of designed 401 + + :: + + File "/opt/odoo/vendor/it-projects-llc/sync-addons/openapi/controllers/pinguin.py", line 152, in authenticate_token_for_user + raise werkzeug.exceptions.HTTPException(response=error_response(*CODE__no_user_auth)) + HTTPException: ??? Unknown Error: None + +* TODO: ``wrap__resource__create_one`` method makes ``cr.commit()``. We need to avoid that. +* TODO: add code examples for other programming languages in index.html. The examples can be based on generated swagger clients. The idea of the scripts must be the same as for python (search for partner, create if it doesn't exist, send message) +* TODO: use sudo for log creating and disable write access rights +* TODO: finish unitttests (see ``test_api.py``) +* TODO: ``.../swagger.json`` url doesn't work in multi-db mode in odoo 12.0 at least: it make strange redirection to from ``/api/v1/demo/swagger.json?token=demo_token&db=source`` to ``/api/v1/demo/swagger.json?token%3Ddemo_token%26db%3Dsource`` +* TODO: remove access to create logs and use sudo (SUPERUSER_ID) instead. It prevents making fake logs by malicous user +* TODO: Check that swagger specification and module documentaiton covers how to pass context to method calls + +Questions? +========== + +To get an assistance on this module contact us by email :arrow_right: help@itpp.dev + +Contributors +============ +* `David Arnold `__ +* `Ivan Yelizariev `__ +* `Rafis Bikbov `__ +* `Stanislav Krotov `__ + +* `XOE Solutions `__ + +=================== + +Odoo Apps Store: https://apps.odoo.com/apps/modules/15.0/openapi/ + + +Notifications on updates: `via Atom `_, `by Email `_ + +Tested on `Odoo 15.0 `_ diff --git a/openapi/__init__.py b/openapi/__init__.py new file mode 100644 index 00000000..95b5a60d --- /dev/null +++ b/openapi/__init__.py @@ -0,0 +1,8 @@ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + + +def post_load(): + # make import in post_load to avoid applying monkey patches when this + # module is not installed + from . import models + from . import controllers diff --git a/openapi/__manifest__.py b/openapi/__manifest__.py new file mode 100644 index 00000000..e9bb9550 --- /dev/null +++ b/openapi/__manifest__.py @@ -0,0 +1,35 @@ +# Copyright 2018-2019,2022 Ivan Yelizariev +# Copyright 2021 Denis Mudarisov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +{ + "name": """REST API/OpenAPI/Swagger""", + "summary": """RESTful API to integrate Odoo with whatever system you need""", + "category": "", + "images": ["images/openapi-swagger.png"], + "version": "1.2.4", + "application": False, + "author": "IT-Projects LLC, Ivan Yelizariev", + "support": "help@itpp.dev", + "website": "https://t.me/sync_studio", + "license": "LGPL-3", + "depends": ["base_api", "mail"], + "external_dependencies": { + "python": ["bravado_core", "swagger_spec_validator", "jsonschema<4"], + "bin": [], + }, + "data": [ + "security/openapi_security.xml", + "security/ir.model.access.csv", + "security/res_users_token.xml", + "views/openapi_view.xml", + "views/res_users_view.xml", + "views/ir_model_view.xml", + ], + "demo": ["demo/openapi_demo.xml", "demo/openapi_security_demo.xml"], + "post_load": "post_load", + "pre_init_hook": None, + "post_init_hook": None, + "uninstall_hook": None, + "auto_install": False, + "installable": True, +} diff --git a/openapi/controllers/__init__.py b/openapi/controllers/__init__.py new file mode 100644 index 00000000..6bafd309 --- /dev/null +++ b/openapi/controllers/__init__.py @@ -0,0 +1,5 @@ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). + +from . import main +from . import api +from . import pinguin diff --git a/openapi/controllers/api.py b/openapi/controllers/api.py new file mode 100644 index 00000000..efb45165 --- /dev/null +++ b/openapi/controllers/api.py @@ -0,0 +1,227 @@ +# Copyright 2018, XOE Solutions +# Copyright 2019,2022 Ivan Yelizariev +# Copyright 2018 Rafis Bikbov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +# pylint: disable=redefined-builtin +import logging + +from odoo import http +from odoo.http import request + +from . import pinguin + +_logger = logging.getLogger(__name__) + +################################################################# +# Odoo REST API # +# Version 1 # +# --------------------------------------------------------------# +# The current api version is considered stable, although # +# the exposed models and methods change as they are configured # +# on the database level. Only if significant changes in the api # +# generation logic should be implemented in the future # +# a version bump should be considered. # +################################################################# + +API_ENDPOINT = "/api" +API_ENDPOINT_V1 = "/v1" +# API_ENDPOINT_V2 = '/v2' + +# We patch the route decorator in pinguin.py +# with authentication and DB inference logic. +# We also check if the model is installed in the database. +# Furthermore we check if api version is supported. +# This keeps the code below minial and readable. + + +class ApiV1Controller(http.Controller): + """Implements the REST API V1 endpoint. + .. methods: + + CRUD Methods: + - `POST .../` -> `CreateOne` + - `PUT ...//` -> `UpdateOne` + - `GET .../` -> `ReadMulti` + - `GET ...//` -> `ReadOne` + - `DELETE ...//` -> `UnlinkOne` + + Auxiliary Methods: + - `PATCH ...///` -> `Call Method on Singleton Record` + - `PATCH ...//` -> `Call Method on RecordSet` + - `GET .../report/pdf/` -> `Get Report as PDF` + - `GET .../report/html/` -> `Get Report as HTML` + """ + + _api_endpoint = API_ENDPOINT + API_ENDPOINT_V1 + _api_endpoint = _api_endpoint + "/" + # CreateOne # ReadMulti + _api_endpoint_model = _api_endpoint + "/" + # ReadOne # UpdateOne # UnlinkOne + _api_endpoint_model_id = _api_endpoint + "//" + # Call Method on Singleton Record + _api_endpoint_model_id_method = ( + _api_endpoint + "///call/" + ) + # Call Method on RecordSet + _api_endpoint_model_method = _api_endpoint + "//call/" + _api_endpoint_model_method_ids = _api_endpoint + "//call//" + # Get Reports + _api_report_docids = ( + _api_endpoint + + "/report///" + ) + + # ################# + # # CRUD Methods ## + # ################# + + # CreateOne + @http.route( + _api_endpoint_model, methods=["POST"], type="http", auth="none", csrf=False + ) + @pinguin.route + def create_one__POST(self, namespace, model): + data = request.get_json_data() + conf = pinguin.get_model_openapi_access(namespace, model) + pinguin.method_is_allowed( + "api_create", conf["method"], main=True, raise_exception=True + ) + # FIXME: What is contained in context and for what? + # # If context is not a python dict + # # TODO unwrap + # if isinstance(kw.get('context'), basestring): + # context = get_create_context(namespace, model, kw.get('context')) + # else: + # context = kw.get('context') or {} + return pinguin.wrap__resource__create_one( + modelname=model, + context=conf["context"], + data=data, + success_code=pinguin.CODE__created, + out_fields=conf["out_fields_read_one"], + ) + + # ReadMulti (optional: filters, offset, limit, order, include_fields, exclude_fields): + @http.route( + _api_endpoint_model, methods=["GET"], type="http", auth="none", csrf=False + ) + @pinguin.route + def read_multi__GET(self, namespace, model, **kw): + print("read_multi__GET - " * 10) + conf = pinguin.get_model_openapi_access(namespace, model) + pinguin.method_is_allowed( + "api_read", conf["method"], main=True, raise_exception=True + ) + return pinguin.wrap__resource__read_all( + modelname=model, + success_code=pinguin.CODE__success, + out_fields=conf["out_fields_read_multi"], + ) + + # ReadOne (optional: include_fields, exclude_fields) + @http.route( + _api_endpoint_model_id, methods=["GET"], type="http", auth="none", csrf=False + ) + @pinguin.route + def read_one__GET(self, namespace, model, id, **kw): + conf = pinguin.get_model_openapi_access(namespace, model) + pinguin.method_is_allowed( + "api_read", conf["method"], main=True, raise_exception=True + ) + return pinguin.wrap__resource__read_one( + modelname=model, + id=id, + success_code=pinguin.CODE__success, + out_fields=conf["out_fields_read_one"], + ) + + # UpdateOne + @http.route( + _api_endpoint_model_id, methods=["PUT"], type="http", auth="none", csrf=False + ) + @pinguin.route + def update_one__PUT(self, namespace, model, id): + data = request.get_json_data() + conf = pinguin.get_model_openapi_access(namespace, model) + pinguin.method_is_allowed( + "api_update", conf["method"], main=True, raise_exception=True + ) + return pinguin.wrap__resource__update_one( + modelname=model, id=id, success_code=pinguin.CODE__ok_no_content, data=data + ) + + # UnlinkOne + @http.route( + _api_endpoint_model_id, methods=["DELETE"], type="http", auth="none", csrf=False + ) + @pinguin.route + def unlink_one__DELETE(self, namespace, model, id): + conf = pinguin.get_model_openapi_access(namespace, model) + pinguin.method_is_allowed( + "api_delete", conf["method"], main=True, raise_exception=True + ) + return pinguin.wrap__resource__unlink_one( + modelname=model, id=id, success_code=pinguin.CODE__ok_no_content + ) + + # ###################### + # # Auxiliary Methods ## + # ###################### + + # Call Method on Singleton Record (optional: method parameters) + @http.route( + _api_endpoint_model_id_method, + methods=["PATCH"], + type="http", + auth="none", + csrf=False, + ) + @pinguin.route + def call_method_one__PATCH(self, namespace, model, id, method_name): + method_params = request.get_json_data() + conf = pinguin.get_model_openapi_access(namespace, model) + pinguin.method_is_allowed(method_name, conf["method"]) + return pinguin.wrap__resource__call_method( + modelname=model, + ids=[id], + method=method_name, + method_params=method_params, + success_code=pinguin.CODE__success, + ) + + # Call Method on RecordSet (optional: method parameters) + @http.route( + [_api_endpoint_model_method, _api_endpoint_model_method_ids], + methods=["PATCH"], + type="http", + auth="none", + csrf=False, + ) + @pinguin.route + def call_method_multi__PATCH(self, namespace, model, method_name, ids=None): + method_params = request.get_json_data() + conf = pinguin.get_model_openapi_access(namespace, model) + pinguin.method_is_allowed(method_name, conf["method"]) + ids = ids and ids.split(",") or [] + ids = [int(i) for i in ids] + return pinguin.wrap__resource__call_method( + modelname=model, + ids=ids, + method=method_name, + method_params=method_params, + success_code=pinguin.CODE__success, + ) + + # Get Report + @http.route( + _api_report_docids, methods=["GET"], type="http", auth="none", csrf=False + ) + @pinguin.route + def report__GET(self, converter, namespace, report_external_id, docids): + return pinguin.wrap__resource__get_report( + namespace=namespace, + report_external_id=report_external_id, + docids=docids, + converter=converter, + success_code=pinguin.CODE__success, + ) diff --git a/openapi/controllers/main.py b/openapi/controllers/main.py new file mode 100644 index 00000000..fc6aa671 --- /dev/null +++ b/openapi/controllers/main.py @@ -0,0 +1,51 @@ +# Copyright 2018 Ivan Yelizariev +# Copyright 2018 Rafis Bikbov +# Copyright 2021 Denis Mudarisov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import json +import logging + +import werkzeug + +from odoo import http +from odoo.tools import date_utils + +from odoo.addons.web.controllers.utils import ensure_db + +_logger = logging.getLogger(__name__) + + +class OAS(http.Controller): + @http.route( + "/api/v1//swagger.json", + type="http", + auth="none", + csrf=False, + ) + def OAS_json_spec_download(self, namespace_name, **kwargs): + ensure_db() + namespace = ( + http.request.env["openapi.namespace"] + .sudo() + .search([("name", "=", namespace_name)]) + ) + if not namespace: + raise werkzeug.exceptions.NotFound() + if namespace.token != kwargs.get("token"): + raise werkzeug.exceptions.Forbidden() + + response_params = {"headers": [("Content-Type", "application/json")]} + if "download" in kwargs: + response_params = { + "headers": [ + ("Content-Type", "application/octet-stream; charset=binary"), + ("Content-Disposition", http.content_disposition("swagger.json")), + ], + "direct_passthrough": True, + } + + return werkzeug.wrappers.Response( + json.dumps(namespace.get_OAS(), default=date_utils.json_default), + status=200, + **response_params + ) diff --git a/openapi/controllers/pinguin.py b/openapi/controllers/pinguin.py new file mode 100644 index 00000000..d617ed45 --- /dev/null +++ b/openapi/controllers/pinguin.py @@ -0,0 +1,945 @@ +# Copyright 2018, XOE Solutions +# Copyright 2018-2019 Rafis Bikbov +# Copyright 2019 Yan Chirino +# Copyright 2019 Anvar Kildebekov +# Copyright 2021 Denis Mudarisov +# Copyright 2022 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +# pylint: disable=redefined-builtin + +"""Pinguin module for Odoo REST Api. + +This module implements plumbing code to the REST interface interface concerning +authentication, validation, ORM access and error codes. + +It also implements a ORP API worker in the future (maybe). + +Todo: + * Implement API worker + * You have to also use ``sphinx.ext.todo`` extension + +.. _Google Python Style Guide: + https://google.github.io/styleguide/pyguide.html +""" +import base64 +import functools +import traceback + +import werkzeug.wrappers + +import odoo +from odoo.http import request +from odoo.service import security + +from odoo.addons.base_api.lib.pinguin import ( + error_response, + get_dict_from_record, + get_dictlist_from_model, + get_model_for_read, +) +from odoo.addons.web.controllers.main import ReportController + +try: + import simplejson as json +except ImportError: + import json + + +#################################### +# Definition of global error codes # +#################################### + +# 2xx Success +CODE__success = 200 +CODE__created = 201 +CODE__accepted = 202 +CODE__ok_no_content = 204 +# 4xx Client Errors +CODE__server_rejects = (400, "Server rejected", "Welcome to macondo!") +CODE__no_user_auth = (401, "Authentication", "Your token could not be authenticated.") +CODE__user_no_perm = (403, "Permissions", "%s") +CODE__method_blocked = ( + 403, + "Blocked Method", + "This method is not whitelisted on this model.", +) +CODE__db_not_found = (404, "Db not found", "Welcome to macondo!") +CODE__canned_ctx_not_found = ( + 404, + "Canned context not found", + "The requested canned context is not configured on this model", +) +CODE__obj_not_found = ( + 404, + "Object not found", + "This object is not available on this instance.", +) +CODE__res_not_found = (404, "Resource not found", "There is no resource with this id.") +CODE__act_not_executed = ( + 409, + "Action not executed", + "The requested action was not executed.", +) +# 5xx Server errors +CODE__invalid_method = (501, "Invalid Method", "This method is not implemented.") +CODE__invalid_spec = ( + 501, + "Invalid Field Spec", + "The field spec supplied is not valid.", +) +# If API Workers are enforced, but non is available (switched off) +CODE__no_api_worker = ( + 503, + "API worker sleeping", + "The API worker is currently not at work.", +) + + +def successful_response(status, data=None): + """Successful responses wrapper. + + :param int status: The success code. + :param data: (optional). The data that can be converted to a JSON. + + :returns: The werkzeug `response object`_. + :rtype: werkzeug.wrappers.Response + + .. _response object: + http://werkzeug.pocoo.org/docs/0.14/wrappers/#module-werkzeug.wrappers + + """ + try: + data = data.ids + except AttributeError: + pass + + return request.make_json_response(data, status=status) + + +########################## +# Pinguin Authentication # +########################## + + +# User token auth (db-scoped) +def authenticate_token_for_user(token): + """Authenticate against the database and setup user session corresponding to the token. + + :param str token: The raw access token. + + :returns: User if token is authorized for the requested user. + :rtype odoo.models.Model + + :raise: werkzeug.exceptions.HTTPException if user not found. + """ + user = request.env["res.users"].sudo().search([("openapi_token", "=", token)]) + if user.exists(): + # copy-pasted from odoo.http.py:OpenERPSession.authenticate() + request.session.uid = user.id + request.session.login = user.login + request.session.session_token = user.id and security.compute_session_token( + request.session, request.env + ) + request.update_env(user=user.id) + + return user + raise werkzeug.exceptions.HTTPException( + response=error_response(*CODE__no_user_auth) + ) + + +def get_auth_header(headers, raise_exception=False): + """check and get basic authentication header from headers + + :param werkzeug.datastructures.Headers headers: All headers in request. + :param bool raise_exception: raise exception. + + :returns: Found raw authentication header. + :rtype: str or None + + :raise: werkzeug.exceptions.HTTPException if raise_exception is **True** + and auth header is not in headers + or it is not Basic type. + """ + auth_header = headers.get("Authorization") or headers.get("authorization") + if not auth_header or not auth_header.startswith("Basic "): + if raise_exception: + raise werkzeug.exceptions.HTTPException( + response=error_response(*CODE__no_user_auth) + ) + return auth_header + + +def get_data_from_auth_header(header): + """decode basic auth header and get data + + :param str header: The raw auth header. + + :returns: a tuple of database name and user token + :rtype: tuple + :raise: werkzeug.exceptions.HTTPException if basic header is invalid base64 + string or if the basic header is + in the wrong format + """ + normalized_token = header.replace("Basic ", "").replace("\\n", "").encode("utf-8") + try: + decoded_token_parts = ( + base64.b64decode(normalized_token).decode("utf-8").split(":") + ) + except TypeError as e: + raise werkzeug.exceptions.HTTPException( + response=error_response( + 500, "Invalid header", "Basic auth header must be valid base64 string" + ) + ) from e + + if len(decoded_token_parts) == 1: + db_name, user_token = None, decoded_token_parts[0] + elif len(decoded_token_parts) == 2: + db_name, user_token = decoded_token_parts + else: + err_descrip = ( + 'Basic auth header payload must be of the form "<%s>" (encoded to base64)' + % "user_token" + if odoo.tools.config["dbfilter"] + else "db_name:user_token" + ) + raise werkzeug.exceptions.HTTPException( + response=error_response(500, "Invalid header", err_descrip) + ) + + return db_name, user_token + + +def setup_db(httprequest, db_name): + """check and setup db in session by db name + + :param httprequest: a wrapped werkzeug Request object + :type httprequest: :class:`werkzeug.wrappers.BaseRequest` + :param str db_name: Database name. + + :raise: werkzeug.exceptions.HTTPException if the database not found. + """ + if httprequest.session.db: + return + if db_name not in odoo.service.db.list_dbs(force=True): + raise werkzeug.exceptions.HTTPException( + response=error_response(*CODE__db_not_found) + ) + + httprequest.session.db = db_name + + +################### +# Pinguin Routing # +################### + + +# Try to get namespace from user allowed namespaces +def get_namespace_by_name_from_users_namespaces( + user, namespace_name, raise_exception=False +): + """check and get namespace from users namespaces by name + + :param ..models.res_users.ResUsers user: The user record. + :param str namespace_name: The name of namespace. + :param bool raise_exception: raise exception if namespace does not exist. + + :returns: Found 'openapi.namespace' record. + :rtype: ..models.openapi_namespace.Namespace + + :raise: werkzeug.exceptions.HTTPException if the namespace is not contained + in allowed user namespaces. + """ + namespace = request.env["openapi.namespace"].search([("name", "=", namespace_name)]) + + if not namespace.exists() and raise_exception: + raise werkzeug.exceptions.HTTPException( + response=error_response(*CODE__obj_not_found) + ) + + if namespace not in user.namespace_ids and raise_exception: + err = list(CODE__user_no_perm) + err[2] = "The requested namespace (integration) is not authorized." + raise werkzeug.exceptions.HTTPException(response=error_response(*err)) + + return namespace + + +# Create openapi.log record +def create_log_record(**kwargs): + test_mode = request.registry.test_cr + # don't create log in test mode as it's impossible in case of error in sql + # request (we cannot use second cursor and we cannot use aborted + # transaction) + if not test_mode: + with odoo.registry(request.session.db).cursor() as cr: + # use new to save data even in case of an error in the old cursor + env = odoo.api.Environment(cr, request.session.uid, {}) + _create_log_record(env, **kwargs) + + +def _create_log_record( + env, + namespace_id=None, + namespace_log_request=None, + namespace_log_response=None, + user_id=None, + user_request=None, + user_response=None, +): + """create log for request + + :param int namespace_id: Requested namespace id. + :param string namespace_log_request: Request save option. + :param string namespace_log_response: Response save option. + :param int user_id: User id which requests. + :param user_request: a wrapped werkzeug Request object from user. + :type user_request: :class:`werkzeug.wrappers.BaseRequest` + :param user_response: a wrapped werkzeug Response object to user. + :type user_response: :class:`werkzeug.wrappers.Response` + + :returns: New 'openapi.log' record. + :rtype: ..models.openapi_log.Log + """ + if True: # just to keep original indent + log_data = { + "namespace_id": namespace_id, + "request": "%s | %s | %d" + % (user_request.url, user_request.method, user_response.status_code), + "request_data": None, + "response_data": None, + } + if namespace_log_request == "debug": + log_data["request_data"] = user_request.__dict__ + elif namespace_log_request == "info": + log_data["request_data"] = user_request.__dict__ + for k in ["form", "files"]: + try: + del log_data["request_data"][k] + except KeyError: + pass + + if namespace_log_response == "debug": + log_data["response_data"] = user_response.__dict__ + elif namespace_log_response == "error" and user_response.status_code > 400: + log_data["response_data"] = user_response.__dict__ + + return env["openapi.log"].create(log_data) + + +# Patched http route +def route(controller_method): + """Set up the environment for route handlers. + + Patches the framework and additionally authenticates + the API token and infers database through a different mechanism. + + :param list args: Positional arguments. Transparent pass through to the patched method. + :param dict kwargs: Keyword arguments. Transparent pass through to the patched method. + + :returns: wrapped method + """ + if True: # dummy if-block to keep original indent + + @functools.wraps(controller_method) + def controller_method_wrapper(*iargs, **ikwargs): + + auth_header = get_auth_header( + request.httprequest.headers, raise_exception=True + ) + db_name, user_token = get_data_from_auth_header(auth_header) + authenticated_user = authenticate_token_for_user(user_token) + namespace = get_namespace_by_name_from_users_namespaces( + authenticated_user, ikwargs["namespace"], raise_exception=True + ) + data_for_log = { + "namespace_id": namespace.id, + "namespace_log_request": namespace.log_request, + "namespace_log_response": namespace.log_response, + "user_id": authenticated_user.id, + "user_request": None, + "user_response": None, + } + + try: + response = controller_method(*iargs, **ikwargs) + except werkzeug.exceptions.HTTPException as e: + response = e.response + except Exception as e: + traceback.print_exc() + if hasattr(e, "error") and isinstance(e.error, Exception): + e = e.error + response = error_response( + status=500, + error=type(e).__name__, + error_descrip=e.name if hasattr(e, "name") else str(e), + ) + + data_for_log.update( + {"user_request": request.httprequest, "user_response": response} + ) + create_log_record(**data_for_log) + + return response + + return controller_method_wrapper + + +############################ +# Pinguin Metadata Helpers # +############################ + + +# TODO: cache per model and database +# Get the specific context(openapi.access) +def get_create_context(namespace, model, canned_context): + """Get the requested preconfigured context of the model specification. + + The canned context is used to preconfigure default values or context flags. + That are used in a repetitive way in namespace for specific model. + + As this should, for performance reasons, not repeatedly result in calls to the persistence + layer, this method is cached in memory. + + :param str namespace: The namespace to also validate against. + :param str model: The model, for which we retrieve the configuration. + :param str canned_context: The preconfigured context, which we request. + + :returns: A dictionary containing the requested context. + :rtype: dict + :raise: werkzeug.exceptions.HTTPException TODO: add description in which case + """ + cr, uid = request.cr, request.session.uid + + # Singleton by construction (_sql_constraints) + openapi_access = request.env(cr, uid)["openapi.access"].search( + [("model_id", "=", model), ("namespace_id.name", "=", namespace)] + ) + + assert ( + len(openapi_access) == 1 + ), "'openapi_access' is not a singleton, bad construction." + # Singleton by construction (_sql_constraints) + context = openapi_access.create_context_ids.filtered( + lambda r: r["name"] == canned_context + ) + assert len(context) == 1, "'context' is not a singleton, bad construction." + + if not context: + raise werkzeug.exceptions.HTTPException( + response=error_response(*CODE__canned_ctx_not_found) + ) + + return context + + +# TODO: cache per model and database +# Get model configuration (openapi.access) +def get_model_openapi_access(namespace, model): + """Get the model configuration and validate the requested namespace against the session. + + The namespace is a lightweight ACL + default implementation to integrate + with various integration consumer, such as webstore, provisioning platform, etc. + + We validate the namespace at this latter stage, because it forms part of the http route. + The token has been related to a namespace already previously + + This is a double purpose method. + + As this should, for performance reasons, not repeatedly result in calls to the persistence + layer, this method is cached in memory. + + :param str namespace: The namespace to also validate against. + :param str model: The model, for which we retrieve the configuration. + + :returns: The error response object if namespace validation failed. + A dictionary containing the model API configuration for this namespace. + The layout of the dict is as follows: + ```python + {'context': (Dict) odoo context (default values through context), + 'out_fields_read_multi': (Tuple) field spec, + 'out_fields_read_one': (Tuple) field spec, + 'out_fields_create_one': (Tuple) field spec, + 'method' : { + 'public' : { + 'mode': (String) one of 'all', 'none', 'custom', + 'whitelist': (List) of method strings, + }, + 'private' : { + 'mode': (String) one of 'none', 'custom', + 'whitelist': (List) of method strings, + }, + 'main' : { + 'mode': (String) one of 'none', 'custom', + 'whitelist': (List) of method strings, + }, + } + ``` + :rtype: dict + :raise: werkzeug.exceptions.HTTPException if the namespace has no accesses. + """ + # TODO: this method has code duplicates with openapi specification code (e.g. get_OAS_definitions_part) + cr, uid = request.cr, request.session.uid + # Singleton by construction (_sql_constraints) + openapi_access = ( + request.env(cr, uid)["openapi.access"] + .sudo() + .search([("model_id", "=", model), ("namespace_id.name", "=", namespace)]) + ) + if not openapi_access.exists(): + raise werkzeug.exceptions.HTTPException( + response=error_response(*CODE__canned_ctx_not_found) + ) + + res = { + "context": {}, # Take ot here FIXME: make sure it is for create_context + "out_fields_read_multi": (), + "out_fields_read_one": (), + "out_fields_create_one": (), # FIXME: for what? + "method": { + "public": {"mode": "", "whitelist": []}, + "private": {"mode": "", "whitelist": []}, + "main": {"mode": "", "whitelist": []}, + }, + } + # Infer public method mode + if openapi_access.api_public_methods and openapi_access.public_methods: + res["method"]["public"]["mode"] = "custom" + res["method"]["public"]["whitelist"] = openapi_access.public_methods.split() + elif openapi_access.api_public_methods: + res["method"]["public"]["mode"] = "all" + else: + res["method"]["public"]["mode"] = "none" + + # Infer private method mode + if openapi_access.private_methods: + res["method"]["private"]["mode"] = "custom" + res["method"]["private"]["whitelist"] = openapi_access.private_methods.split() + else: + res["method"]["private"]["mode"] = "none" + + for c in openapi_access.create_context_ids.mapped("context"): + res["context"].update(json.loads(c)) + + res["out_fields_read_multi"] = openapi_access.read_many_id.export_fields.mapped( + "name" + ) or ("id",) + res["out_fields_read_one"] = openapi_access.read_one_id.export_fields.mapped( + "name" + ) or ("id",) + + if openapi_access.public_methods: + res["method"]["public"]["whitelist"] = openapi_access.public_methods.split() + if openapi_access.private_methods: + res["method"]["private"]["whitelist"] = openapi_access.private_methods.split() + + main_methods = ["api_create", "api_read", "api_update", "api_delete"] + for method in main_methods: + if openapi_access[method]: + res["method"]["main"]["whitelist"].append(method) + + if len(res["method"]["main"]["whitelist"]) == len(main_methods): + res["method"]["main"]["mode"] = "all" + elif not res["method"]["main"]["whitelist"]: + res["method"]["main"]["mode"] = "none" + else: + res["method"]["main"]["mode"] = "custom" + + return res + + +################## +# Pinguin Worker # +################## + + +def wrap__resource__create_one(modelname, context, data, success_code, out_fields): + """Function to create one record. + + :param str model: The name of the model. + :param dict context: TODO + :param dict data: Data received from the user. + :param int success_code: The success code. + :param tuple out_fields: Canned fields. + + :returns: successful response if the create operation is performed + otherwise error response + :rtype: werkzeug.wrappers.Response + """ + model_obj = get_model_for_read(modelname) + try: + created_obj = model_obj.with_context(context).create(data) + test_mode = request.registry.test_cr + if not test_mode: + # Somehow don't making a commit here may lead to error + # "Record does not exist or has been deleted" + # Probably, Odoo (10.0 at least) uses different cursors + # to create and to read fields from database + request.env.cr.commit() + except Exception as e: + return error_response(400, type(e).__name__, str(e)) + + out_data = get_dict_from_record(created_obj, out_fields, (), ()) + return successful_response(success_code, out_data) + + +def wrap__resource__read_all(modelname, success_code, out_fields): + """function to read all records. + + :param str modelname: The name of the model. + :param int success_code: The success code. + :param tuple out_fields: Canned fields. + + :returns: successful response with records data + :rtype: werkzeug.wrappers.Response + """ + data = get_dictlist_from_model(modelname, out_fields) + return successful_response(success_code, data) + + +def wrap__resource__read_one(modelname, id, success_code, out_fields): + """Function to read one record. + + :param str modelname: The name of the model. + :param int id: The record id of which we want to read. + :param int success_code: The success code. + :param tuple out_fields: Canned fields. + + :returns: successful response with the data of one record + :rtype: werkzeug.wrappers.Response + """ + out_data = get_dict_from_model(modelname, out_fields, id) + return successful_response(success_code, out_data) + + +def wrap__resource__update_one(modelname, id, success_code, data): + """Function to update one record. + + :param str modelname: The name of the model. + :param int id: The record id of which we want to update. + :param int success_code: The success code. + :param dict data: The data for update. + + :returns: successful response if the update operation is performed + otherwise error response + :rtype: werkzeug.wrappers.Response + """ + cr, uid = request.cr, request.session.uid + record = request.env(cr, uid)[modelname].browse(id) + if not record.exists(): + return error_response(*CODE__obj_not_found) + try: + record.write(data) + except Exception as e: + return error_response(400, type(e).__name__, str(e)) + return successful_response(success_code) + + +def wrap__resource__unlink_one(modelname, id, success_code): + """Function to delete one record. + + :param str modelname: The name of the model. + :param int id: The record id of which we want to delete. + :param int success_code: The success code. + + :returns: successful response if the delete operation is performed + otherwise error response + :rtype: werkzeug.wrappers.Response + """ + cr, uid = request.cr, request.session.uid + record = request.env(cr, uid)[modelname].browse([id]) + if not record.exists(): + return error_response(*CODE__obj_not_found) + record.unlink() + return successful_response(success_code) + + +def wrap__resource__call_method(modelname, ids, method, method_params, success_code): + """Function to call the model method for records by IDs. + + :param str modelname: The name of the model. + :param list ids: The record ids of which we want to call method. + :param str method: The name of the method. + :param int success_code: The success code. + + :returns: successful response if the method execution did not cause an error + otherwise error response + :rtype: werkzeug.wrappers.Response + """ + model_obj = get_model_for_read(modelname) + + if not hasattr(model_obj, method): + return error_response(*CODE__invalid_method) + + records = model_obj.browse(ids).exists() + results = [] + args = method_params.get("args", []) + kwargs = method_params.get("kwargs", {}) + for record in records or [model_obj]: + result = getattr(record, method)(*args, **kwargs) + results.append(result) + + if len(ids) <= 1 and len(results): + results = results[0] + model_obj.flush_model() # to recompute fields + return successful_response(success_code, data=results) + + +def wrap__resource__get_report( + namespace, report_external_id, docids, converter, success_code +): + """Return html or pdf report response. + + :param namespace: id/ids/browserecord of the records to print (if not used, pass an empty list) + :param docids: id/ids/browserecord of the records to print (if not used, pass an empty list) + :param docids: id/ids/browserecord of the records to print (if not used, pass an empty list) + :param report_name: Name of the template to generate an action for + """ + report = request.env.ref(report_external_id) + + if isinstance(report, type(request.env["ir.ui.view"])): + report = request.env["report"]._get_report_from_name(report_external_id) + + model = report.model + report_name = report.report_name + + get_model_openapi_access(namespace, model) + + response = ReportController().report_routes(report_name, docids, converter) + response.status_code = success_code + return response + + +####################### +# Pinguin ORM Wrapper # +####################### + + +# Dict from model +def get_dict_from_model(model, spec, id, **kwargs): + """Fetch dictionary from one record according to spec. + + :param str model: The model against which to validate. + :param tuple spec: The spec to validate. + :param int id: The id of the record. + :param dict kwargs: Keyword arguments. + :param tuple kwargs['include_fields']: The extra fields. + This parameter is not implemented on higher level code in order + to serve as a soft ACL implementation on top of the framework's + own ACL. + :param tuple kwargs['exclude_fields']: The excluded fields. + + :returns: The python dictionary of the requested values. + :rtype: dict + :raise: werkzeug.exceptions.HTTPException if the record does not exist. + """ + include_fields = kwargs.get( + "include_fields", () + ) # Not actually implemented on higher level (ACL!) + exclude_fields = kwargs.get("exclude_fields", ()) + + model_obj = get_model_for_read(model) + + record = model_obj.browse([id]) + if not record.exists(): + raise werkzeug.exceptions.HTTPException( + response=error_response(*CODE__res_not_found) + ) + return get_dict_from_record(record, spec, include_fields, exclude_fields) + + +# Check that the method is allowed +def method_is_allowed(method, methods_conf, main=False, raise_exception=False): + """Check that the method is allowed for the specified settings. + + :param str method: The name of the method. + :param dict methods_conf: The methods configuration dictionary. + A dictionary containing the methods API configuration. + The layout of the dict is as follows: + ```python + { + 'public' : { + 'mode': (String) one of 'all', 'none', 'custom', + 'whitelist': (List) of method strings, + }, + 'private' : { + 'mode': (String) one of 'none', 'custom', + 'whitelist': (List) of method strings, + }, + 'main' : { + 'mode': (String) one of 'none', 'custom', + 'whitelist': (List) of method strings, + }, + } + ``` + :param bool main: The method is a one of access fields. + :param bool raise_exception: raise exception instead of returning **False**. + + :returns: **True** if the method is allowed, otherwise **False**. + :rtype: bool + :raise: werkzeug.exceptions.HTTPException if the method is not allowed and + raise_exception is **True**. + """ + if main: + method_type = "main" + else: + method_type = "private" if method.startswith("_") else "public" + + if methods_conf[method_type]["mode"] == "all": + return True + if ( + methods_conf[method_type]["mode"] == "custom" + and method in methods_conf[method_type]["whitelist"] + ): + return True + if raise_exception: + raise werkzeug.exceptions.HTTPException( + response=error_response(*CODE__method_blocked) + ) + return False + + +############### +# Pinguin OAS # +############### + +# Get definition name +def get_definition_name(modelname, prefix="", postfix="", splitter="-"): + """Concatenation of the prefix, modelname, postfix. + + :param str modelname: The name of the model. + :param str prefix: The prefix. + :param str postfix: The postfix. + :param str splitter: The splitter. + + :returns: Concatenation of the arguments + :rtype: str + """ + return splitter.join([s for s in [prefix, modelname, postfix] if s]) + + +# Get OAS definitions part for model and nested models +def get_OAS_definitions_part( + model_obj, export_fields_dict, definition_prefix="", definition_postfix="" +): + """Recursively gets definition parts of the OAS for model by export fields. + + :param odoo.models.Model model_obj: The model object. + :param dict export_fields_dict: The dictionary with export fields. + Example of the dict is as follows: + ```python + { + u'active': None, + u'child_ids': { + u'user_ids': { + u'city': None, + u'login': None, + u'password': None, + u'id': None}, u'id': None + }, + u'email': None, + u'name': None + } + ``` + + :param str definition_prefix: The prefix for definition name. + :param str definition_postfix: The postfix for definition name. + + :returns: Definitions for the model and relative models. + :rtype: dict + """ + definition_name = get_definition_name( + model_obj._name, definition_prefix, definition_postfix + ) + + definitions = { + definition_name: {"type": "object", "properties": {}, "required": []}, + } + + fields_meta = model_obj.fields_get(export_fields_dict.keys()) + + for field, child_fields in export_fields_dict.items(): + meta = fields_meta[field] + if child_fields: + child_model = model_obj.env[meta["relation"]] + child_definition = get_OAS_definitions_part( + child_model, child_fields, definition_prefix=definition_name + ) + + if meta["type"].endswith("2one"): + field_property = child_definition[ + get_definition_name(child_model._name, prefix=definition_name) + ] + else: + field_property = { + "type": "array", + "items": child_definition[ + get_definition_name(child_model._name, prefix=definition_name) + ], + } + else: + field_property = {} + + if meta["type"] == "integer": + field_property.update(type="integer") + elif meta["type"] == "float": + field_property.update(type="number", format="float") + elif meta["type"] == "monetary": + field_property.update(type="number", format="float") + elif meta["type"] == "char": + field_property.update(type="string") + elif meta["type"] == "text": + field_property.update(type="string") + elif meta["type"] == "binary": + field_property.update(type="string", format="binary") + elif meta["type"] == "boolean": + field_property.update(type="boolean") + elif meta["type"] == "date": + field_property.update(type="string", format="date") + elif meta["type"] == "datetime": + field_property.update(type="string", format="date-time") + elif meta["type"] == "many2one": + field_property.update(type="integer") + elif meta["type"] == "selection": + field_property.update( + { + "type": "integer" + if isinstance(meta["selection"][0][0], int) + else "string", + "enum": [i[0] for i in meta["selection"]], + } + ) + elif meta["type"] in ["one2many", "many2many"]: + field_property.update({"type": "array", "items": {"type": "integer"}}) + + # We cannot have both required and readOnly flags in field openapi + # definition, for that reason we cannot blindly use odoo's + # attributed readonly and required. + # + # 1) For odoo-required, but NOT odoo-related field, we do NOT use + # openapi-readonly + # + # Example of such field can be found in sale module: + # partner_id = fields.Many2one('res.partner', readonly=True, + # states={'draft': [('readonly', False)], 'sent': [('readonly', + # False)]}, required=True, ...) + # + # 2) For odoo-required and odoo-related field, we DO use + # openapi-readonly, but we don't use openapi-required + if meta["readonly"] and (not meta["required"] or meta.get("related")): + field_property.update(readOnly=True) + + definitions[definition_name]["properties"][field] = field_property + + if meta["required"] and not meta.get("related"): + fld = model_obj._fields[field] + # Mark as required only if field doesn't have defaul value + # Boolean always has default value (False) + if fld.default is None and fld.type != "boolean": + definitions[definition_name]["required"].append(field) + + if not definitions[definition_name]["required"]: + del definitions[definition_name]["required"] + + return definitions diff --git a/openapi/demo/openapi_demo.xml b/openapi/demo/openapi_demo.xml new file mode 100644 index 00000000..829f9f67 --- /dev/null +++ b/openapi/demo/openapi_demo.xml @@ -0,0 +1,65 @@ + + + + + demo + debug + debug + demo_token + + + + demo / res.partner / read_one + res.partner + + + + demo / res.partner / read_many + res.partner + + + + default + + {"default_function": "CEO"} + + + + + + + + + + _email_send + + + + + diff --git a/openapi/demo/openapi_security_demo.xml b/openapi/demo/openapi_security_demo.xml new file mode 100644 index 00000000..4f3e0e1a --- /dev/null +++ b/openapi/demo/openapi_security_demo.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/openapi/doc/changelog.rst b/openapi/doc/changelog.rst new file mode 100644 index 00000000..36c3c13c --- /dev/null +++ b/openapi/doc/changelog.rst @@ -0,0 +1,87 @@ +`1.2.4` +------- + +- **Fix:** stopped using the internal mechanism of work with CORS because it caused conflicts +- **Fix:** added consumes field to PUT method because there were problems when working with a Swagger +- **Fix:** internal exception handling was incorrect +- **Fix:** there was no description of the monetary field + +`1.2.3` +------- + +- **Fix:** error on logging with info level + +`1.2.2` +------- + +- **Fix:** error while working with two or more databases + +`1.2.1` +------- + +- **Fix:** error on creating openapi. namespace record with context presets +- **Fix:** OpenAPI user can't create log records +- **Fix:** wrong handling error in PATCH method +- **Fix:** Error "Object of type datetime is not JSON serializable" in json + response + +`1.2.0` +------- + +- **Improvement:** Improve archive options and toggle button + +`1.1.9` +------- + +- **Fix:** Possible crash on reading multiple openapi.namespace records at once + +`1.1.8` +------- + +- **Fix:** Slow loading of integration view due to loading logs in python + +`1.1.7` +------- + +- **Improvement:** namespace's logs displays in standalone tree-view by clicking smart-button + +`1.1.6` +------- + +- **Fix:** users got same token on installation + +`1.1.5` +------- + +- **Fix:** UTF-8 Decode error, singleton error in check methods + +`1.1.4` +------- + +- **Fix:** no computation In PATCH-method for compute-fields + +`1.1.3` +------- + +- **Fix:** don't mark fields as readOnly and required at the same time + +`1.1.2` +------- + +- **Improvement:** Security rules for reading and configuration + +`1.1.1` +------- + +- **Fix:** Translation model's float-field into JSON's integer + +`1.1.0` +------- + +- **New:** search_or_create method is available in all models +- **Improvement:** no need to use extra quotes in context + +`1.0.0` +------- + +- Init version diff --git a/openapi/doc/index.rst b/openapi/doc/index.rst new file mode 100644 index 00000000..0de1cedb --- /dev/null +++ b/openapi/doc/index.rst @@ -0,0 +1,173 @@ +========================== + REST API/Openapi/Swagger +========================== + +.. contents:: + :local: + +Installation +============ + +* Install python packages: + + ``python3 -m pip install bravado_core swagger_spec_validator`` + +* `Install `__ this module in a usual way + +Configuration +============= + +Activating and customization +---------------------------- + +* Open menu ``[[ OpenAPI ]] >> OpenAPI >> Integrations`` +* Click ``[Create]`` +* Specify **Name** for integration, e.g. ``test`` +* Set **Log requests** to *Full* +* Set **Log responses** to *Full* +* In ``Accessable models`` tab click ``Add an item`` + + * Set **Model**, for example *res.users* + * Configure allowed operations + + * **[x] Create via API** + + * Set **Creation Context Presets**, for example + + * **Name**: ``brussels`` + * **Context**: ``{"default_tz":"Europe/Brussels", "default_lang":"fr_BE"}`` + + * **[x] Read via API** + + * Set **Read One Fields** -- fields to return on reading one record + * Set **Read Many Fields** -- fields to return on reading multiple records + + Note: you can use Export widget in corresponding *Model* to create *Fields list*. To do that: + + * Open menu for the *Model* + * Switch to list view + * Select any record + * click ``[Action] -> Export`` + * Set **Export Type** to *Export all Data* + * Add the fields you need to right column + * Click **Save fields list**, choose name and save the list + * Now the list is availab to set **Read One Fields**, **Read Many Fields** settings + + * **[x] Update via API** + * **[x] Delete via API** + +* Click ``[Save]`` +* Copy **Specification Link** to use it in any system that support OpenAPI + +Authentication +-------------- + +* `Activate Developer Mode `__ +* Open menu ``[[ Settings ]] >> Users & Companies >> Users`` +* Select a user that will be used for iteracting over API +* In **Allowed Integration** select some integrations +* Copy **OpenAPI Token** to use it in any system that support OpenAPI + +If necessary, you can reset the token by pressing ``[Reset OpenAPI Token]`` button + +Usage +===== + +Swagger Editor +-------------- +As the simplest example, you can try API in Swagger Editor. It allows to review and check API + +* Open http://editor.swagger.io/ +* Click menu ``File >> Import URL`` +* Set **Specification link** +* RESULT: Specification is parsed succefully and you can see API presentation +* Click ``[Authorize]`` button + + * **Username** -- set database name + * **Password** -- set **OpenAPI Token** (how to get one is described in `authentication <#authentication>`__ above) + +Note: + The Swagger Editor sends requests directly from browser which leads to CORS error and work with it is not available in `odoo.sh`. + The easiest solution is to simply copy-past the curl command from Swagger Editor and run it from the terminal. + + Alternatively, you can grant CORS headers in your web server. Below is example for Nginx:: + + location /api/v1 { + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' '*' 'always'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE, PATCH' 'always'; + add_header 'Access-Control-Allow-Headers' 'Authorization,DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' 'always'; + add_header 'Access-Control-Max-Age' 1728000; + add_header 'Content-Type' 'text/plain; charset=utf-8'; + add_header 'Content-Length' 0; + return 204; + } + if ($request_method = 'POST') { + add_header 'Access-Control-Allow-Origin' '*' 'always'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE, PATCH' 'always'; + add_header 'Access-Control-Allow-Headers' 'Authorization,DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' 'always'; + } + if ($request_method = 'GET') { + add_header 'Access-Control-Allow-Origin' '*' 'always'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE, PATCH' 'always'; + add_header 'Access-Control-Allow-Headers' 'Authorization,DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' 'always'; + } + if ($request_method = 'PUT') { + add_header 'Access-Control-Allow-Origin' '*' 'always'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE, PATCH' 'always'; + add_header 'Access-Control-Allow-Headers' 'Authorization,DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' 'always'; + } + if ($request_method = 'DELETE') { + add_header 'Access-Control-Allow-Origin' '*' 'always'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE, PATCH' 'always'; + add_header 'Access-Control-Allow-Headers' 'Authorization,DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' 'always'; + } + if ($request_method = 'PATCH') { + add_header 'Access-Control-Allow-Origin' '*' 'always'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, PUT, DELETE, PATCH' 'always'; + add_header 'Access-Control-Allow-Headers' 'Authorization,DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' 'always'; + } + + # ... + } + +How to call methods with arguments via API +------------------------------------------ + +Here is an example of calling a search method with domain. + +This is how it is usually done from python code: + +.. code-block:: python + + partner_ids = self.env["res.partner"].search([("is_company", "=", "True")]) + +On using API it would be as following: + +.. code-block:: bash + + curl -X PATCH "http://example.com/api/v1/demo/res.partner/call/search" -H "accept: application/json" \ + -H "authorization: Basic BASE64_ENCODED_EXPRESSION" -H "Content-Type: application/json" \ + -d '{ "args": [[["is_company", "=", "True" ]]]}' + + +Updating existing record +----------------------------- + +For example, to set *phone* value for a partner, make a PUT request in the following way: + +.. code-block:: bash + + curl -X PUT -H "Authorization: Basic BASE64_ENCODED_EXPRESSION" \ + -H "Content-Type: application/json" -H "Accept: */*" \ + -d '{ "phone": "+7123456789"}' "http://example.com/api/v1/demo/res.partner/41" + +To set many2one field, you need to pass id as a value: + +.. code-block:: bash + + curl -X PUT -H "Authorization: Basic BASE64_ENCODED_EXPRESSION" \ + -H "Content-Type: application/json" -H "Accept: */*" \ + -d '{ "parent_id": *RECORD_ID*}' "http://example.com/api/v1/demo/res.partner/41" + +For more examples visit https://itpp.dev/sync website diff --git a/openapi/i18n/fr.po b/openapi/i18n/fr.po new file mode 100644 index 00000000..11bc725f --- /dev/null +++ b/openapi/i18n/fr.po @@ -0,0 +1,535 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * openapi +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-09-15 18:12+0000\n" +"PO-Revision-Date: 2022-09-15 20:25+0200\n" +"Last-Translator: \n" +"Language-Team: Alpis Traduction et Interpétation \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: fr\n" +"X-Generator: Poedit 2.0.4\n" + +#. module: openapi +#: model:ir.model.constraint,message:openapi.constraint_openapi_namespace_name_uniq +msgid "" +"A namespace already exists with this name. Namespace's name must be unique!" +msgstr "" +"Un espace de noms existe déjà avec ce préfixe. Le préfixe de l'espace de noms " +"doit être unique !" + +#. module: openapi +#: model_terms:ir.ui.view,arch_db:openapi.access_form_view +msgid "Access" +msgstr "Accès" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_ir_model__api_access_ids +msgid "Access via API" +msgstr "Accès via API" + +#. module: openapi +#: model:ir.model,name:openapi.model_openapi_access +msgid "Access via API " +msgstr "Accès via API " + +#. module: openapi +#: model_terms:ir.ui.view,arch_db:openapi.namespace_form_view +msgid "Accessable Models" +msgstr "Modèles accessibles" + +#. module: openapi +#: model:ir.actions.act_window,name:openapi.ir_model_accesses_list_action +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__access_ids +msgid "Accesses" +msgstr "Accès" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__active +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__active +msgid "Active" +msgstr "Activer" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__private_methods +msgid "Allow Private methods" +msgstr "Autoriser les méthodes privées" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_res_users__namespace_ids +msgid "Allowed Integrations" +msgstr "Intégrations autorisées" + +#. module: openapi +#: model_terms:ir.ui.view,arch_db:openapi.res_users_view_form +msgid "Allowed Intergrations" +msgstr "Intégrations autorisées" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__user_ids +#: model_terms:ir.ui.view,arch_db:openapi.namespace_form_view +msgid "Allowed Users" +msgstr "Utilisateurs autorisés" + +#. module: openapi +#: model:ir.model.fields,help:openapi.field_openapi_access__private_methods +msgid "" +"Allowed private methods. Private methods are ones that start with underscore. " +"Format: one method per line. When empty -- private methods are not allowed" +msgstr "" +"Méthodes privées autorisées. Les méthodes privées sont celles qui commencent " +"par un souligné. Format : une méthode par ligne. Si vide -- les méthodes " +"privées ne sont pas autorisées" + +#. module: openapi +#: model:ir.model.fields,help:openapi.field_openapi_access__public_methods +msgid "" +"Allowed public methods besides basic ones.\n" +"Public methods are ones that don't start with underscore).\n" +"Format: one method per line.\n" +"When empty -- all public methods are allowed" +msgstr "" +"Méthodes publiques autorisées en plus des méthodes de base.\n" +"Les méthodes publiques sont celles qui ne commencent pas par un souligné).\n" +"Format : une méthode par ligne.\n" +"Lorsque vide -- toutes les méthodes publiques sont autorisées" + +#. module: openapi +#: model_terms:ir.ui.view,arch_db:openapi.access_form_view +#: model_terms:ir.ui.view,arch_db:openapi.namespace_form_view +msgid "Archived" +msgstr "Archivé" + +#. module: openapi +#: model:ir.model.fields,help:openapi.field_res_users__openapi_token +msgid "Authentication token for access to API (/api)." +msgstr "Jeton d'authentification pour l'accès à l'API (/api)." + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__api_public_methods +msgid "Call Public methods via API" +msgstr "Appeler les méthodes publiques via l'API" + +#. module: openapi +#: model:ir.model.fields,help:openapi.field_openapi_access__create_context_ids +msgid "Can be used to pass default values or custom context" +msgstr "" +"Peut être utilisé pour passer des valeurs par défaut ou un contexte personnalisé" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access_create_context__context +msgid "Context" +msgstr "Contexte" + +#. module: openapi +#: code:addons/openapi/models/openapi_access.py:0 +#, python-format +msgid "Context must be jsonable." +msgstr "Le contexte doit être jsonable." + +#. module: openapi +#: model:ir.model,name:openapi.model_openapi_access_create_context +msgid "Context on creating via API " +msgstr "Contexte de création via API " + +#. module: openapi +#: model_terms:ir.actions.act_window,help:openapi.namespace_list_action +msgid "Create and manage the namespaces." +msgstr "Créez et gérez les espaces de noms." + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__api_create +msgid "Create via API" +msgstr "Créer via l'API" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__create_uid +#: model:ir.model.fields,field_description:openapi.field_openapi_access_create_context__create_uid +#: model:ir.model.fields,field_description:openapi.field_openapi_log__create_uid +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__create_uid +msgid "Created by" +msgstr "Créé par" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__create_date +#: model:ir.model.fields,field_description:openapi.field_openapi_access_create_context__create_date +#: model:ir.model.fields,field_description:openapi.field_openapi_log__create_date +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__create_date +msgid "Created on" +msgstr "Créé le" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__create_context_ids +#: model_terms:ir.ui.view,arch_db:openapi.access_form_view +msgid "Creation Context Presets" +msgstr "Préréglages du contexte de création" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__api_delete +msgid "Delete via API" +msgstr "Supprimer via l'API" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access_create_context__description +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__description +msgid "Description" +msgstr "Description" + +#. module: openapi +#: model:ir.model.fields.selection,name:openapi.selection__openapi_namespace__log_request__disabled +#: model:ir.model.fields.selection,name:openapi.selection__openapi_namespace__log_response__disabled +msgid "Disabled" +msgstr "Désactivé" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_ir_exports__display_name +#: model:ir.model.fields,field_description:openapi.field_ir_model__display_name +#: model:ir.model.fields,field_description:openapi.field_openapi_access__display_name +#: model:ir.model.fields,field_description:openapi.field_openapi_access_create_context__display_name +#: model:ir.model.fields,field_description:openapi.field_openapi_log__display_name +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__display_name +#: model:ir.model.fields,field_description:openapi.field_res_users__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: openapi +#: model_terms:ir.ui.view,arch_db:openapi.namespace_form_view +#: model_terms:ir.ui.view,arch_db:openapi.res_users_view_form +msgid "Do you want to proceed reset token?" +msgstr "Voulez-vous procéder à la réinitialisation du jeton ?" + +#. module: openapi +#: model:ir.model.fields.selection,name:openapi.selection__openapi_namespace__log_response__error +msgid "Errors only" +msgstr "Seulement les erreurs" + +#. module: openapi +#: model_terms:ir.ui.view,arch_db:openapi.access_form_view +#: model_terms:ir.ui.view,arch_db:openapi.ir_exports_form_view +msgid "Export Fields" +msgstr "Exporter les champs" + +#. module: openapi +#: model:ir.model,name:openapi.model_ir_exports +#: model_terms:ir.ui.view,arch_db:openapi.ir_exports_form_view +msgid "Exports" +msgstr "Exports" + +#. module: openapi +#: model:ir.model.fields,help:openapi.field_openapi_access__read_one_id +msgid "Fields to return on reading one record, on creating a record" +msgstr "" +"Champs à retourner à la lecture d'un enregistrement, à la création d'un " +"enregistrement" + +#. module: openapi +#: model:ir.model.fields,help:openapi.field_openapi_access__read_many_id +msgid "Fields to return on reading via non one-record endpoint" +msgstr "" +"Champs à retourner à la lecture via un point de terminaison autre qu'un " +"enregistrement" + +#. module: openapi +#: model:ir.model.fields.selection,name:openapi.selection__openapi_namespace__log_request__debug +#: model:ir.model.fields.selection,name:openapi.selection__openapi_namespace__log_response__debug +msgid "Full" +msgstr "Complet" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_ir_exports__id +#: model:ir.model.fields,field_description:openapi.field_ir_model__id +#: model:ir.model.fields,field_description:openapi.field_openapi_access__id +#: model:ir.model.fields,field_description:openapi.field_openapi_access_create_context__id +#: model:ir.model.fields,field_description:openapi.field_openapi_log__id +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__id +#: model:ir.model.fields,field_description:openapi.field_res_users__id +msgid "ID" +msgstr "Identifiant" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__token +msgid "Identification token" +msgstr "Jeton d'identification" + +#. module: openapi +#: model:ir.model,name:openapi.model_openapi_namespace +#: model:ir.model.fields,field_description:openapi.field_openapi_access__namespace_id +#: model:ir.model.fields,field_description:openapi.field_openapi_log__namespace_id +msgid "Integration" +msgstr "Intégration" + +#. module: openapi +#: model:ir.model.fields,help:openapi.field_openapi_namespace__name +msgid "" +"Integration name, e.g. ebay, amazon, magento, etc.\n" +" The name is used in api endpoint" +msgstr "" +"Nom de l'intégration, par exemple ebay, amazon, magento, etc.\n" +" Le nom est utilisé dans le point de terminaison api" + +#. module: openapi +#: model_terms:ir.ui.view,arch_db:openapi.namespace_form_view +msgid "Intergration" +msgstr "Intégration" + +#. module: openapi +#: model:ir.actions.act_window,name:openapi.namespace_list_action +#: model:ir.ui.menu,name:openapi.namespaces_menu +msgid "Intergrations" +msgstr "Intégrations" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_ir_exports____last_update +#: model:ir.model.fields,field_description:openapi.field_ir_model____last_update +#: model:ir.model.fields,field_description:openapi.field_openapi_access____last_update +#: model:ir.model.fields,field_description:openapi.field_openapi_access_create_context____last_update +#: model:ir.model.fields,field_description:openapi.field_openapi_log____last_update +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace____last_update +#: model:ir.model.fields,field_description:openapi.field_res_users____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__write_uid +#: model:ir.model.fields,field_description:openapi.field_openapi_access_create_context__write_uid +#: model:ir.model.fields,field_description:openapi.field_openapi_log__write_uid +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__write_uid +msgid "Last Updated by" +msgstr "Dernière mise à jour par" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__write_date +#: model:ir.model.fields,field_description:openapi.field_openapi_access_create_context__write_date +#: model:ir.model.fields,field_description:openapi.field_openapi_log__write_date +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__write_date +msgid "Last Updated on" +msgstr "Dernière Mise à Jour le" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__last_log_date +msgid "Latest usage" +msgstr "Dernière utilisation" + +#. module: openapi +#: model_terms:ir.ui.view,arch_db:openapi.openapi_log_model_view_form +msgid "Log" +msgstr "Journal d'erreurs" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__log_request +msgid "Log Requests" +msgstr "Enregistrer des appels" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__log_response +msgid "Log Responses" +msgstr "Enregistrer les réponses" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__log_count +msgid "Log count" +msgstr "Nombre d’enregistrements" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__log_ids +#: model_terms:ir.ui.view,arch_db:openapi.namespace_form_view +msgid "Logs" +msgstr "Journaux" + +#. module: openapi +#: model:res.groups,name:openapi.group_manager +msgid "Manager" +msgstr "Gestionnaire" + +#. module: openapi +#: code:addons/openapi/models/openapi_access.py:0 +#, python-format +msgid "" +"Method %r is not part of the model's method list:\n" +" %r" +msgstr "" +"La méthode %r ne fait pas partie de la liste de méthodes du modèle :\n" +" %r" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__model_id +#: model:ir.model.fields,field_description:openapi.field_openapi_access_create_context__model_id +msgid "Model" +msgstr "Modèle" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__model +msgid "Model Name" +msgstr "Nom du modéle" + +#. module: openapi +#: model:ir.model,name:openapi.model_ir_model +msgid "Models" +msgstr "Modèles" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access_create_context__name +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__name +msgid "Name" +msgstr "Nom" + +#. module: openapi +#: model:ir.module.category,name:openapi.module_management +#: model:ir.ui.menu,name:openapi.main_openapi_menu +#: model:ir.ui.menu,name:openapi.openapi_menu +msgid "OpenAPI" +msgstr "OpenAPI" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_res_users__openapi_token +msgid "OpenAPI Token" +msgstr "Jeton OpenAPI" + +#. module: openapi +#: model:ir.model,name:openapi.model_openapi_log +msgid "OpenAPI logs" +msgstr "Journaux OpenAPI" + +#. module: openapi +#: code:addons/openapi/models/openapi_access.py:0 +#, python-format +msgid "Private method (starting with \"_\" listed in public methods whitelist)" +msgstr "" +"Méthode privée (commençant par \"_\" répertorié dans la liste blanche des " +"méthodes publiques)" + +#. module: openapi +#: code:addons/openapi/models/openapi_access.py:0 +#, python-format +msgid "Public method (not starting with \"_\" listed in private methods whitelist" +msgstr "" +"Méthode publique (ne commençant pas par \"_\" répertoriée dans la liste blanche " +"des méthodes privées" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__read_many_id +#: model_terms:ir.ui.view,arch_db:openapi.access_form_view +msgid "Read Many Fields" +msgstr "Lire plusieurs champs" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__read_one_id +#: model_terms:ir.ui.view,arch_db:openapi.access_form_view +msgid "Read One Fields" +msgstr "Lire un champ" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__api_read +msgid "Read via API" +msgstr "Lire via API" + +#. module: openapi +#: model_terms:ir.ui.view,arch_db:openapi.view_model_form +msgid "Related openapi accesses" +msgstr "Accès openapi kiés" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_ir_model__api_accesses_count +msgid "Related openapi accesses count" +msgstr "Nombre d'accès openapi liés" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_log__request +msgid "Request" +msgstr "Demande" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_log__request_data +msgid "Request Data" +msgstr "Demande de données" + +#. module: openapi +#: model_terms:ir.ui.view,arch_db:openapi.res_users_view_form +msgid "Reset OpenAPI Token" +msgstr "Réinitialiser le jeton OpenAPI" + +#. module: openapi +#: model_terms:ir.ui.view,arch_db:openapi.namespace_form_view +msgid "Reset Token" +msgstr "Réinitialiser le jeton" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_log__response_data +msgid "Response Data" +msgstr "Données de réponse" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__public_methods +msgid "Restric Public methods" +msgstr "Restreindre les méthodes publiques" + +#. module: openapi +#: model:ir.model.fields.selection,name:openapi.selection__openapi_namespace__log_request__info +msgid "Short" +msgstr "Court" + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_namespace__spec_url +msgid "Specification Link" +msgstr "Lien de spécification" + +#. module: openapi +#: code:addons/openapi/models/openapi_access.py:0 +#, python-format +msgid "The model \"%s\" has no such field: \"%s\"." +msgstr "Le modèle \"%s\" n'a pas ce champ : \"%s\"." + +#. module: openapi +#: model:ir.model.constraint,message:openapi.constraint_openapi_access_create_context_context_model_name_uniq +msgid "There is already a context with the same name for this Model" +msgstr "Il existe déjà un contexte portant le même nom pour ce modèle" + +#. module: openapi +#: model:ir.model.constraint,message:openapi.constraint_openapi_access_namespace_model_uniq +msgid "There is already a record for this Model" +msgstr "Il y a déjà un enregistrement pour ce modèle" + +#. module: openapi +#: model:ir.model.fields,help:openapi.field_openapi_namespace__token +msgid "Token passed by a query string parameter to access the specification." +msgstr "" +"Jeton passé par un paramètre de chaîne de requête pour accéder à la " +"spécification." + +#. module: openapi +#: model:ir.model.fields,field_description:openapi.field_openapi_access__api_update +msgid "Update via API" +msgstr "Mettre à jour via API" + +#. module: openapi +#: model:res.groups,name:openapi.group_user +msgid "User" +msgstr "Utilisateur" + +#. module: openapi +#: model:ir.module.category,description:openapi.module_management +msgid "User access level for OpenAPI" +msgstr "Niveau d'accès utilisateur pour OpenAPI" + +#. module: openapi +#: model:ir.model,name:openapi.model_res_users +msgid "Users" +msgstr "Utilisateurs" + +#. module: openapi +#: code:addons/openapi/models/ir_exports.py:0 +#, python-format +msgid "You must delete the \"%s\" field or \"%s\" field" +msgstr "Vous devez supprimer le champ « %s » ou le champ « %s »" + +#. module: openapi +#: code:addons/openapi/models/openapi_access.py:0 +#, python-format +msgid "You must select at least one API method for \"%s\" model." +msgstr "Vous devez sélectionner au moins une méthode API pour le modèle \"%s\"." diff --git a/openapi/images/openapi-swagger.png b/openapi/images/openapi-swagger.png new file mode 100644 index 00000000..8c684cba Binary files /dev/null and b/openapi/images/openapi-swagger.png differ diff --git a/openapi/models/__init__.py b/openapi/models/__init__.py new file mode 100644 index 00000000..f024137a --- /dev/null +++ b/openapi/models/__init__.py @@ -0,0 +1,7 @@ +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html). +from . import openapi_log +from . import openapi_namespace +from . import ir_model +from . import openapi_access +from . import res_users +from . import ir_exports diff --git a/openapi/models/ir_exports.py b/openapi/models/ir_exports.py new file mode 100644 index 00000000..3ac77a00 --- /dev/null +++ b/openapi/models/ir_exports.py @@ -0,0 +1,33 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import _, api, exceptions, models + + +class IrExports(models.Model): + _inherit = "ir.exports" + + @api.constrains("resource", "export_fields") + def _check_fields(self): + # this exports record used in openapi.access + if not self.env["openapi.access"].search_count( + ["|", ("read_one_id", "=", self.id), ("read_many_id", "=", self.id)] + ): + return True + + fields = self.export_fields.mapped("name") + for field in fields: + field_count = fields.count(field) + if field_count > 1: + self.export_fields.search( + [("name", "=", field)], limit=field_count - 1 + ).unlink() + + fields.sort() + for i in range(len(fields) - 1): + if fields[i + 1].startswith(fields[i]) and "/" in fields[i + 1].replace( + fields[i], "" + ): + raise exceptions.ValidationError( + _('You must delete the "%s" field or "%s" field') + % (fields[i], fields[i + 1]) + ) diff --git a/openapi/models/ir_model.py b/openapi/models/ir_model.py new file mode 100644 index 00000000..aa185757 --- /dev/null +++ b/openapi/models/ir_model.py @@ -0,0 +1,18 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import fields, models + + +class IrModel(models.Model): + _inherit = "ir.model" + + api_access_ids = fields.One2many("openapi.access", "model_id", "Access via API") + api_accesses_count = fields.Integer( + compute="_compute_related_accesses_count", + string="Related openapi accesses count", + store=False, + ) + + def _compute_related_accesses_count(self): + for record in self: + record.api_accesses_count = len(record.api_access_ids) diff --git a/openapi/models/openapi_access.py b/openapi/models/openapi_access.py new file mode 100644 index 00000000..b26cc7e4 --- /dev/null +++ b/openapi/models/openapi_access.py @@ -0,0 +1,536 @@ +# Copyright 2018-2019 Ivan Yelizariev +# Copyright 2018 Rafis Bikbov +# Copyright 2021 Denis Mudarisov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import collections +import inspect +import json +import types +import urllib.parse as urlparse +from inspect import getmro, isclass + +from odoo import _, api, exceptions, fields, models + +from odoo.addons.base_api.lib.pinguin import transform_strfields_to_dict + +from ..controllers import pinguin + +PARAM_ID = { + "name": "id", + "in": "path", + "description": "Record ID", + "required": True, + "type": "integer", + "format": "int64", +} + + +class Access(models.Model): + _name = "openapi.access" + _description = "Access via API " + + active = fields.Boolean("Active", default=True) + namespace_id = fields.Many2one("openapi.namespace", "Integration", required=True) + model_id = fields.Many2one("ir.model", "Model", required=True, ondelete="cascade") + model = fields.Char("Model Name", related="model_id.model") + api_create = fields.Boolean("Create via API", default=False) + api_read = fields.Boolean("Read via API", default=False) + api_update = fields.Boolean("Update via API", default=False) + api_delete = fields.Boolean("Delete via API", default=False) + # Options for Public methods: + # * all forbidden + # * all allowed + # * some are allowed + api_public_methods = fields.Boolean( + "Call Public methods via API", + default=False, + ) + public_methods = fields.Text( + "Restric Public methods", + help="Allowed public methods besides basic ones.\n" + "Public methods are ones that don't start with underscore).\n" + "Format: one method per line.\n" + "When empty -- all public methods are allowed", + ) + # Options for Private methods + # * all forbidden + # * some are allowed + private_methods = fields.Text( + "Allow Private methods", + help="Allowed private methods. " + "Private methods are ones that start with underscore. " + "Format: one method per line. " + "When empty -- private methods are not allowed", + ) + read_one_id = fields.Many2one( + "ir.exports", + "Read One Fields", + help="Fields to return on reading one record, on creating a record", + domain="[('resource', '=', model)]", + ) + read_many_id = fields.Many2one( + "ir.exports", + "Read Many Fields", + help="Fields to return on reading via non one-record endpoint", + domain="[('resource', '=', model)]", + ) + create_context_ids = fields.Many2many( + "openapi.access.create.context", + string="Creation Context Presets", + help="Can be used to pass default values or custom context", + domain="[('model_id', '=', model_id)]", + ) + + _sql_constraints = [ + ( + "namespace_model_uniq", + "unique (namespace_id, model_id)", + "There is already a record for this Model", + ) + ] + + @api.model + def _get_method_list(self): + return { + m[0] for m in getmembers(self.env[self.model], predicate=inspect.ismethod) + } + + @api.constrains("public_methods") + def _check_public_methods(self): + for access in self: + if not access.public_methods: + continue + for line in access.public_methods.split("\n"): + if not line: + continue + if line.startswith("_"): + raise exceptions.ValidationError( + _( + 'Private method (starting with "_" listed in public methods whitelist)' + ) + ) + if line not in self._get_method_list(): + raise exceptions.ValidationError( + _("Method %r is not part of the model's method list:\n %r") + % (line, self._get_method_list()) + ) + + @api.constrains("private_methods") + def _check_private_methods(self): + for access in self: + if not access.private_methods: + continue + for line in access.private_methods.split("\n"): + if not line: + continue + if not line.startswith("_"): + raise exceptions.ValidationError( + _( + 'Public method (not starting with "_" listed in private methods whitelist' + ) + ) + if line not in self._get_method_list(): + raise exceptions.ValidationError( + _("Method %r is not part of the model's method list:\n %r") + % (line, self._get_method_list()) + ) + + @api.constrains("api_create", "api_read", "api_update", "api_delete") + def _check_methods(self): + for record in self: + methods = [ + record.api_create, + record.api_read, + record.api_update, + record.api_delete, + record.api_public_methods, + ] + methods += (record.public_methods or "").split("\n") + methods += (record.private_methods or "").split("\n") + if all(not m for m in methods): + raise exceptions.ValidationError( + _('You must select at least one API method for "%s" model.') + % record.model + ) + + def name_get(self): + return [ + (record.id, "{}/{}".format(record.namespace_id.name, record.model)) + for record in self + ] + + def get_OAS_paths_part(self): + model_name = self.model + read_many_path = "/%s" % model_name + read_one_path = "%s/{id}" % read_many_path + patch_one_path = read_one_path + "/call/{method_name}" + patch_model_path = read_many_path + "/call/{method_name}" + patch_many_path = read_many_path + "/call/{method_name}/{ids}" + + read_many_definition_ref = "#/definitions/%s" % pinguin.get_definition_name( + self.model, "", "read_many" + ) + read_one_definition_ref = "#/definitions/%s" % pinguin.get_definition_name( + self.model, "", "read_one" + ) + patch_definition_ref = "#/definitions/%s" % pinguin.get_definition_name( + self.model, "", "patch" + ) + + capitalized_model_name = "".join( + [s.capitalize() for s in model_name.split(".")] + ) + + paths_object = collections.OrderedDict( + [ + (read_many_path, {}), + (read_one_path, {}), + (patch_model_path, {}), + (patch_many_path, {}), + (patch_one_path, {}), + ] + ) + + if self.api_create: + paths_object[read_many_path]["post"] = { + "summary": "Add a new %s object to the store" % model_name, + "description": "", + "operationId": "add%s" % capitalized_model_name, + "consumes": ["application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "%s object that needs to be added to the store" + % model_name, + "required": True, + "schema": { + "$ref": "#/definitions/%s" + % pinguin.get_definition_name(self.model) + }, + } + ], + "responses": { + "201": { + "description": "successful create", + "schema": {"$ref": "#/definitions/%s-read_one" % model_name}, + }, + }, + } + + if self.api_read: + paths_object[read_many_path]["get"] = { + "summary": "Get all %s objects" % model_name, + "description": "Returns all %s objects" % model_name, + "operationId": "getAll%s" % capitalized_model_name, + "produces": ["application/json"], + "responses": { + "200": { + "description": "A list of %s." % model_name, + "schema": { + "type": "array", + "items": {"$ref": read_many_definition_ref}, + }, + } + }, + } + + paths_object[read_one_path]["get"] = { + "summary": "Get %s by ID" % model_name, + "description": "Returns a single %s" % model_name, + "operationId": "get%sById" % capitalized_model_name, + "produces": ["application/json"], + "parameters": [PARAM_ID], + "responses": { + "200": { + "description": "successful operation", + "schema": {"$ref": read_one_definition_ref}, + }, + "404": {"description": "%s not found" % model_name}, + }, + } + + if self.api_update: + paths_object[read_one_path]["put"] = { + "summary": "Update %s by ID" % model_name, + "description": "", + "operationId": "update%sById" % capitalized_model_name, + "consumes": ["application/json"], + "parameters": [ + PARAM_ID, + { + "in": "body", + "name": "body", + "description": "Updated %s object" % model_name, + "required": True, + "schema": { + "$ref": "#/definitions/%s" + % pinguin.get_definition_name(self.model) + }, + }, + ], + "responses": { + "204": {"description": "successful update"}, + "404": {"description": "%s not found" % model_name}, + }, + } + + if self.api_delete: + paths_object[read_one_path]["delete"] = { + "summary": "Delete %s by ID" % model_name, + "description": "", + "operationId": "delete%s" % capitalized_model_name, + "produces": ["application/json"], + "parameters": [PARAM_ID], + "responses": { + "204": {"description": "successful delete"}, + "404": {"description": "%s not found" % model_name}, + }, + } + + if self.api_public_methods or self.public_methods or self.private_methods: + allowed_methods = [] + if self.api_public_methods: + allowed_methods += [ + m for m in self._get_method_list() if not m.startswith("_") + ] + elif self.public_methods: + allowed_methods += [m for m in self.public_methods.split("\n") if m] + if self.private_methods: + allowed_methods += [m for m in self.private_methods.split("\n") if m] + + allowed_methods = list(set(allowed_methods)) + + PARAM_METHOD_NAME = { + "name": "method_name", + "in": "path", + "description": "Method Name", + "required": True, + "type": "string", + "enum": allowed_methods, + } + PARAM_BODY = { + "in": "body", + "name": "body", + "description": "Parameters for calling the method on a recordset", + "schema": {"$ref": patch_definition_ref}, + } + RESPONSES = { + "200": {"description": "successful patch"}, + "403": { + "description": "Requested model method is not allowed", + "schema": {"$ref": "#/definitions/ErrorResponse"}, + }, + } + + paths_object[patch_one_path]["patch"] = { + "summary": "Patch %s by single ID" % model_name, + "description": "Call model method for single record.", + "operationId": "callMethodFor%sSingleRecord" % capitalized_model_name, + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [PARAM_ID, PARAM_METHOD_NAME, PARAM_BODY], + "responses": RESPONSES, + } + + paths_object[patch_model_path]["patch"] = { + "summary": "Patch %s" % model_name, + "description": "Call model method on model", + "operationId": "callMethodFor%sModel" % capitalized_model_name, + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [PARAM_METHOD_NAME, PARAM_BODY], + "responses": RESPONSES, + } + + paths_object[patch_many_path]["patch"] = { + "summary": "Patch %s by some IDs" % model_name, + "description": "Call model method for recordset.", + "operationId": "callMethodFor%sRecordset" % capitalized_model_name, + "consumes": ["application/json"], + "produces": ["application/json"], + "parameters": [ + { + "name": "ids", + "in": "path", + "description": "Comma-separated Record IDS", + "required": True, + "type": "string", + }, + PARAM_METHOD_NAME, + PARAM_BODY, + ], + "responses": RESPONSES, + } + + paths_object = {k: v for k, v in paths_object.items() if v} + for _path_item_key, path_item_value in paths_object.items(): + for path_method in path_item_value.values(): + # add tag + path_method.update({"tags": [model_name]}) + + # add global responses + path_method["responses"].update( + { + 400: {"$ref": "#/responses/400"}, + 401: {"$ref": "#/responses/401"}, + 500: {"$ref": "#/responses/500"}, + } + ) + + return paths_object + + def get_OAS_definitions_part(self): + related_model = self.env[self.model] + export_fields_read_one = transform_strfields_to_dict( + self.read_one_id.export_fields.mapped("name") or ("id",) + ) + export_fields_read_many = transform_strfields_to_dict( + self.read_many_id.export_fields.mapped("name") or ("id",) + ) + definitions = {} + definitions.update( + pinguin.get_OAS_definitions_part( + related_model, export_fields_read_one, definition_postfix="read_one" + ) + ) + definitions.update( + pinguin.get_OAS_definitions_part( + related_model, export_fields_read_many, definition_postfix="read_many" + ) + ) + if self.api_create or self.api_update: + all_fields = transform_strfields_to_dict(related_model._fields) + definitions.update( + pinguin.get_OAS_definitions_part(related_model, all_fields) + ) + + if self.api_public_methods or self.private_methods: + definitions.update( + { + pinguin.get_definition_name(self.model, "", "patch"): { + "type": "object", + "example": { + "args": [], + "kwargs": { + "body": "Message is posted via API by calling message_post method", + "subject": "Test API", + }, + "context": {}, + }, + } + } + ) + return definitions + + def get_OAS_part(self): + self = self.sudo() + return { + "definitions": self.get_OAS_definitions_part(), + "paths": self.get_OAS_paths_part(), + "tag": { + "name": "%s" % self.model, + "description": "Everything about %s" % self.model, + }, + } + + +class AccessCreateContext(models.Model): + _name = "openapi.access.create.context" + _description = "Context on creating via API " + + name = fields.Char("Name", required=True) + description = fields.Char("Description") + model_id = fields.Many2one("ir.model", "Model", required=True, ondelete="cascade") + context = fields.Text("Context", required=True) + + _sql_constraints = [ + ( + "context_model_name_uniq", + "unique (name, model_id)", + "There is already a context with the same name for this Model", + ) + ] + + @api.model + def _fix_name(self, vals): + if "name" in vals: + vals["name"] = urlparse.quote_plus(vals["name"].lower()) + return vals + + @api.model_create_multi + def create(self, vals): + for val in vals: + val = self._fix_name(val) + return super(AccessCreateContext, self).create(vals) + + def write(self, vals): + vals = self._fix_name(vals) + return super(AccessCreateContext, self).write(vals) + + @api.constrains("context") + def _check_context(self): + Model = self.env[self.model_id.model] + fields = Model.fields_get() + for record in self: + try: + data = json.loads(record.context) + except ValueError as e: + raise exceptions.ValidationError(_("Context must be jsonable.")) from e + + for k, _v in data.items(): + if k.startswith("default_") and k[8:] not in fields: + raise exceptions.ValidationError( + _('The model "%s" has no such field: "%s".') % (Model, k[8:]) + ) + + +def getmembers(obj, predicate=None): + # This is copy-pasted method from inspect lib with updates marked as NEW + """Return all members of an object as (name, value) pairs sorted by name. + Optionally, only return members that satisfy a given predicate.""" + if isclass(obj): + mro = (obj,) + getmro(obj) + else: + mro = () + results = [] + processed = set() + names = dir(obj) + # :dd any DynamicClassAttributes to the list of names if object is a class; + # this may result in duplicate entries if, for example, a virtual + # attribute with the same name as a DynamicClassAttribute exists + try: + for base in obj.__bases__: + for k, v in base.__dict__.items(): + if isinstance(v, types.DynamicClassAttribute): + names.append(k) + except AttributeError: + pass + for key in names: + if key == "_cache": + # NEW + # trying to read this key will return error in odoo 11.0+ + # AssertionError: Unexpected RecordCache(res.partner()) + continue + # First try to get the value via getattr. Some descriptors don't + # like calling their __get__ (see bug #1785), so fall back to + # looking in the __dict__. + try: + value = getattr(obj, key) + # handle the duplicate key + if key in processed: + raise AttributeError + except AttributeError: + for base in mro: + if key in base.__dict__: + value = base.__dict__[key] + break + else: + # could be a (currently) missing slot member, or a buggy + # __dir__; discard and move on + continue + if not predicate or predicate(value): + results.append((key, value)) + processed.add(key) + results.sort(key=lambda pair: pair[0]) + return results diff --git a/openapi/models/openapi_log.py b/openapi/models/openapi_log.py new file mode 100644 index 00000000..3017a796 --- /dev/null +++ b/openapi/models/openapi_log.py @@ -0,0 +1,16 @@ +# Copyright 2018 Ivan Yelizariev +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import fields, models + + +class Log(models.Model): + _name = "openapi.log" + _order = "id desc" + _description = "OpenAPI logs" + + namespace_id = fields.Many2one("openapi.namespace", "Integration") + request = fields.Char("Request") + request_data = fields.Text("Request Data") + response_data = fields.Text("Response Data") + # create_uid -- auto field + # create_date -- auto field diff --git a/openapi/models/openapi_namespace.py b/openapi/models/openapi_namespace.py new file mode 100644 index 00000000..63a570cd --- /dev/null +++ b/openapi/models/openapi_namespace.py @@ -0,0 +1,265 @@ +# Copyright 2018-2019 Ivan Yelizariev +# Copyright 2018 Rafis Bikbov +# Copyright 2019 Yan Chirino +# Copyright 2020 Anvar Kildebekov +# Copyright 2021 Denis Mudarisov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import collections +import urllib.parse as urlparse +import uuid + +from odoo import api, fields, models + +from odoo.addons.base_api.lib import pinguin + + +class Namespace(models.Model): + _name = "openapi.namespace" + _description = "Integration" + + active = fields.Boolean("Active", default=True) + name = fields.Char( + "Name", + required=True, + help="""Integration name, e.g. ebay, amazon, magento, etc. + The name is used in api endpoint""", + ) + description = fields.Char("Description") + log_ids = fields.One2many("openapi.log", "namespace_id", string="Logs") + log_count = fields.Integer("Log count", compute="_compute_log_count") + log_request = fields.Selection( + [("disabled", "Disabled"), ("info", "Short"), ("debug", "Full")], + "Log Requests", + default="disabled", + ) + + log_response = fields.Selection( + [("disabled", "Disabled"), ("error", "Errors only"), ("debug", "Full")], + "Log Responses", + default="error", + ) + + last_log_date = fields.Datetime(compute="_compute_last_used", string="Latest usage") + + access_ids = fields.One2many( + "openapi.access", + "namespace_id", + string="Accesses", + context={"active_test": False}, + ) + user_ids = fields.Many2many( + "res.users", string="Allowed Users", default=lambda self: self.env.user + ) + + token = fields.Char( + "Identification token", + default=lambda self: str(uuid.uuid4()), + readonly=True, + required=True, + copy=False, + help="Token passed by a query string parameter to access the specification.", + ) + spec_url = fields.Char("Specification Link", compute="_compute_spec_url") + + _sql_constraints = [ + ( + "name_uniq", + "unique (name)", + "A namespace already exists with this name. Namespace's name must be unique!", + ) + ] + + def name_get(self): + return [ + ( + record.id, + "/api/v1/%s%s" + % ( + record.name, + " (%s)" % record.description if record.description else "", + ), + ) + for record in self + ] + + @api.model + def _fix_name(self, vals): + if "name" in vals: + vals["name"] = urlparse.quote_plus(vals["name"].lower()) + return vals + + @api.model_create_multi + def create(self, vals): + for val in vals: + val = self._fix_name(val) + return super(Namespace, self).create(vals) + + def write(self, vals): + vals = self._fix_name(vals) + return super(Namespace, self).write(vals) + + def get_OAS(self): + current_host = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + parsed_current_host = urlparse.urlparse(current_host) + + report_parameters = [ + { + "name": "report_external_id", + "in": "path", + "description": "Report xml id or report name", + "required": True, + "type": "string", + }, + { + "name": "docids", + "in": "path", + "description": "One identifier or several identifiers separated by commas", + "required": True, + "type": "string", + }, + ] + spec = collections.OrderedDict( + [ + ("swagger", "2.0"), + ("info", {"title": self.name, "version": self.write_date}), + ("host", parsed_current_host.netloc), + ("basePath", "/api/v1/%s" % self.name), + ("schemes", [parsed_current_host.scheme]), + ( + "consumes", + ["multipart/form-data", "application/x-www-form-urlencoded"], + ), + ("produces", ["application/json"]), + ( + "paths", + { + "/report/pdf/{report_external_id}/{docids}": { + "get": { + "summary": "Get PDF report file for %s namespace" + % self.name, + "description": "Returns PDF report file for %s namespace" + % self.name, + "operationId": "getPdfReportFileFor%sNamespace" + % self.name.capitalize(), + "produces": ["application/pdf"], + "responses": { + "200": { + "description": "A PDF report file for %s namespace." + % self.name, + "schema": {"type": "file"}, + } + }, + "parameters": report_parameters, + "tags": ["report"], + } + }, + "/report/html/{report_external_id}/{docids}": { + "get": { + "summary": "Get HTML report file for %s namespace" + % self.name, + "description": "Returns HTML report file for %s namespace" + % self.name, + "operationId": "getHtmlReportFileFor%sNamespace" + % self.name.capitalize(), + "produces": ["application/pdf"], + "responses": { + "200": { + "description": "A HTML report file for %s namespace." + % self.name, + "schema": {"type": "file"}, + } + }, + "parameters": report_parameters, + "tags": ["report"], + } + }, + }, + ), + ( + "definitions", + { + "ErrorResponse": { + "type": "object", + "required": ["error", "error_descrip"], + "properties": { + "error": {"type": "string"}, + "error_descrip": {"type": "string"}, + }, + }, + }, + ), + ( + "responses", + { + "400": { + "description": "Invalid Data", + "schema": {"$ref": "#/definitions/ErrorResponse"}, + }, + "401": { + "description": "Authentication information is missing or invalid", + "schema": {"$ref": "#/definitions/ErrorResponse"}, + }, + "500": { + "description": "Server Error", + "schema": {"$ref": "#/definitions/ErrorResponse"}, + }, + }, + ), + ("securityDefinitions", {"basicAuth": {"type": "basic"}}), + ("security", [{"basicAuth": []}]), + ("tags", []), + ] + ) + + for openapi_access in self.access_ids.filtered("active"): + OAS_part_for_model = openapi_access.get_OAS_part() + spec["tags"].append(OAS_part_for_model["tag"]) + del OAS_part_for_model["tag"] + pinguin.update(spec, OAS_part_for_model) + + return spec + + @api.depends("name", "token") + def _compute_spec_url(self): + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + for record in self: + record.spec_url = "{}/api/v1/{}/swagger.json?token={}&db={}".format( + base_url, + record.name, + record.token, + self._cr.dbname, + ) + + def reset_token(self): + for record in self: + token = str(uuid.uuid4()) + while self.search([("token", "=", token)]).exists(): + token = str(uuid.uuid4()) + record.write({"token": token}) + + def action_show_logs(self): + return { + "name": "Logs", + "view_mode": "tree,form", + "res_model": "openapi.log", + "type": "ir.actions.act_window", + "domain": [["namespace_id", "=", self.id]], + } + + def _compute_last_used(self): + for s in self: + s.last_log_date = ( + s.env["openapi.log"] + .search( + [("namespace_id", "=", s.id), ("create_date", "!=", False)], + limit=1, + order="id desc", + ) + .create_date + ) + + def _compute_log_count(self): + self._cr.execute( + "SELECT COUNT(*) FROM openapi_log WHERE namespace_id=(%s);", [str(self.id)] + ) + self.log_count = self._cr.dictfetchone()["count"] diff --git a/openapi/models/res_users.py b/openapi/models/res_users.py new file mode 100644 index 00000000..a197025a --- /dev/null +++ b/openapi/models/res_users.py @@ -0,0 +1,33 @@ +# Copyright 2018-2019 Ivan Yelizariev +# Copyright 2018 Rafis Bikbov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import uuid + +from odoo import api, fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + namespace_ids = fields.Many2many("openapi.namespace", string="Allowed Integrations") + openapi_token = fields.Char( + "OpenAPI Token", + default=lambda self: self._get_unique_openapi_token(), + required=True, + copy=False, + help="Authentication token for access to API (/api).", + ) + + def reset_openapi_token(self): + for record in self: + record.write({"openapi_token": self._get_unique_openapi_token()}) + + def _get_unique_openapi_token(self): + openapi_token = str(uuid.uuid4()) + while self.search_count([("openapi_token", "=", openapi_token)]): + openapi_token = str(uuid.uuid4()) + return openapi_token + + @api.model + def reset_all_openapi_tokens(self): + self.search([]).reset_openapi_token() diff --git a/openapi/security/ir.model.access.csv b/openapi/security/ir.model.access.csv new file mode 100644 index 00000000..b1cb50a9 --- /dev/null +++ b/openapi/security/ir.model.access.csv @@ -0,0 +1,9 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +user_access_openapi_log,access_openapi_log,model_openapi_log,openapi.group_user,1,0,1,0 +user_access_openapi_namespace,access_openapi_namespace,model_openapi_namespace,openapi.group_user,1,0,0,0 +user_access_openapi_access,access_openapi_access,model_openapi_access,openapi.group_user,1,0,0,0 +user_access_openapi_access_create_context,access_openapi_access_create_context,model_openapi_access_create_context,openapi.group_user,1,0,0,0 +manager_access_openapi_log,access_openapi_log,model_openapi_log,openapi.group_manager,1,1,1,1 +manager_access_openapi_namespace,access_openapi_namespace,model_openapi_namespace,openapi.group_manager,1,1,1,1 +manager_access_openapi_access,access_openapi_access,model_openapi_access,openapi.group_manager,1,1,1,1 +manager_access_openapi_access_create_context,access_openapi_access_create_context,model_openapi_access_create_context,openapi.group_manager,1,1,1,1 diff --git a/openapi/security/openapi_security.xml b/openapi/security/openapi_security.xml new file mode 100644 index 00000000..bc87ba10 --- /dev/null +++ b/openapi/security/openapi_security.xml @@ -0,0 +1,23 @@ + + + + OpenAPI + User access level for OpenAPI + 4 + + + User + + + + + Manager + + + + + diff --git a/openapi/security/res_users_token.xml b/openapi/security/res_users_token.xml new file mode 100644 index 00000000..72b0d3e4 --- /dev/null +++ b/openapi/security/res_users_token.xml @@ -0,0 +1,5 @@ + + + + diff --git a/openapi/static/description/aws.png b/openapi/static/description/aws.png new file mode 100644 index 00000000..362198c0 Binary files /dev/null and b/openapi/static/description/aws.png differ diff --git a/openapi/static/description/dataiku.png b/openapi/static/description/dataiku.png new file mode 100644 index 00000000..8ec64a30 Binary files /dev/null and b/openapi/static/description/dataiku.png differ diff --git a/openapi/static/description/dots.jpg b/openapi/static/description/dots.jpg new file mode 100644 index 00000000..55f4c39e Binary files /dev/null and b/openapi/static/description/dots.jpg differ diff --git a/openapi/static/description/icon.png b/openapi/static/description/icon.png new file mode 100644 index 00000000..b4a8bec2 Binary files /dev/null and b/openapi/static/description/icon.png differ diff --git a/openapi/static/description/openapi-configuration-model.png b/openapi/static/description/openapi-configuration-model.png new file mode 100644 index 00000000..cd58052a Binary files /dev/null and b/openapi/static/description/openapi-configuration-model.png differ diff --git a/openapi/static/description/openapi-configuration-namespace.png b/openapi/static/description/openapi-configuration-namespace.png new file mode 100644 index 00000000..8330ca2e Binary files /dev/null and b/openapi/static/description/openapi-configuration-namespace.png differ diff --git a/openapi/static/description/openapi-logs.png b/openapi/static/description/openapi-logs.png new file mode 100644 index 00000000..b5e8da25 Binary files /dev/null and b/openapi/static/description/openapi-logs.png differ diff --git a/openapi/static/description/powerbi.png b/openapi/static/description/powerbi.png new file mode 100644 index 00000000..a83fb8cb Binary files /dev/null and b/openapi/static/description/powerbi.png differ diff --git a/openapi/static/description/swagger-editor.png b/openapi/static/description/swagger-editor.png new file mode 100644 index 00000000..fcebdcf9 Binary files /dev/null and b/openapi/static/description/swagger-editor.png differ diff --git a/openapi/static/description/syndesis.jpg b/openapi/static/description/syndesis.jpg new file mode 100644 index 00000000..eb0ef50a Binary files /dev/null and b/openapi/static/description/syndesis.jpg differ diff --git a/openapi/tests/__init__.py b/openapi/tests/__init__.py new file mode 100644 index 00000000..ac1499e2 --- /dev/null +++ b/openapi/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_json_spec +from . import test_api diff --git a/openapi/tests/test_api.py b/openapi/tests/test_api.py new file mode 100644 index 00000000..13bfb73c --- /dev/null +++ b/openapi/tests/test_api.py @@ -0,0 +1,291 @@ +# Copyright 2018-2019 Ivan Yelizariev +# Copyright 2018 Rafis Bikbov +# Copyright 2019 Anvar Kildebekov +# Copyright 2021 Denis Mudarisov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging + +import requests + +from odoo import api +from odoo.tests import tagged +from odoo.tests.common import HttpCase, get_db_name +from odoo.tools import config + +from ..controllers import pinguin + +_logger = logging.getLogger(__name__) + +USER_DEMO = "base.user_demo" +USER_ADMIN = "base.user_root" +MESSAGE = "message is posted from API" + + +# TODO: test other methods: +# * /res.partner/call/{method_name} (without recordset) +# * /res.partner/{record_id} + + +@tagged("post_install", "-at_install") +class TestAPI(HttpCase): + def setUp(self): + super(TestAPI, self).setUp() + self.db_name = get_db_name() + self.phantom_env = api.Environment(self.registry.test_cr, self.uid, {}) + self.demo_user = self.phantom_env.ref(USER_DEMO) + self.admin_user = self.phantom_env.ref(USER_ADMIN) + self.model_name = "res.partner" + + def request(self, method, url, auth=None, **kwargs): + kwargs.setdefault("model", self.model_name) + kwargs.setdefault("namespace", "demo") + url = ( + "http://localhost:%d/api/v1/{namespace}" % config["http_port"] + url + ).format(**kwargs) + self.opener = requests.Session() + return self.opener.request( + method, url, timeout=30, auth=auth, json=kwargs.get("data_json") + ) + + def request_from_user(self, user, *args, **kwargs): + kwargs["auth"] = requests.auth.HTTPBasicAuth(self.db_name, user.openapi_token) + return self.request(*args, **kwargs) + + def test_read_many_all(self): + resp = self.request_from_user(self.demo_user, "GET", "/{model}") + self.assertEqual(resp.status_code, pinguin.CODE__success) + # TODO check content + + def test_read_one(self): + record_id = self.phantom_env[self.model_name].search([], limit=1).id + resp = self.request_from_user( + self.demo_user, "GET", "/{model}/{record_id}", record_id=record_id + ) + self.assertEqual(resp.status_code, pinguin.CODE__success) + # TODO check content + + def test_create_one(self): + data_for_create = {"name": "created_from_test", "type": "other"} + resp = self.request_from_user( + self.demo_user, "POST", "/{model}", data_json=data_for_create + ) + self.assertEqual(resp.status_code, pinguin.CODE__created) + created_user = self.phantom_env[self.model_name].browse(resp.json()["id"]) + self.assertEqual(created_user.name, data_for_create["name"]) + + # TODO: doesn't work in test environment + def _test_create_one_with_invalid_data(self): + """create partner without name""" + self.phantom_env = api.Environment(self.registry.test_cr, self.uid, {}) + data_for_create = {"email": "string"} + resp = self.request_from_user( + self.demo_user, "POST", "/{model}", data_json=data_for_create + ) + self.assertEqual(resp.status_code, 400) + + def test_update_one(self): + data_for_update = { + "name": "for update in test", + } + partner = self.phantom_env[self.model_name].search([], limit=1) + resp = self.request_from_user( + self.demo_user, + "PUT", + "/{model}/{record_id}", + record_id=partner.id, + data_json=data_for_update, + ) + self.assertEqual(resp.status_code, pinguin.CODE__ok_no_content) + self.assertEqual(partner.name, data_for_update["name"]) + # TODO: check result + + # TODO: doesn't work in test environment + def _test_unlink_one(self): + partner = self.phantom_env[self.model_name].create( + {"name": "record for deleting from test"} + ) + resp = self.request_from_user( + self.demo_user, "DELETE", "/{model}/{record_id}", record_id=partner.id + ) + self.assertEqual(resp.status_code, pinguin.CODE__ok_no_content) + self.assertFalse(self.phantom_env[self.model_name].browse(partner.id).exists()) + # TODO: check result + + def test_unauthorized_user(self): + resp = self.request("GET", "/{model}") + self.assertEqual(resp.status_code, pinguin.CODE__no_user_auth[0]) + + # TODO: doesn't work in test environment + def _test_invalid_dbname(self): + db_name = "invalid_db_name" + resp = self.request( + "GET", + "/{model}", + auth=requests.auth.HTTPBasicAuth(db_name, self.demo_user.openapi_token), + ) + self.assertEqual(resp.status_code, pinguin.CODE__db_not_found[0]) + self.assertEqual(resp.json()["error"], pinguin.CODE__db_not_found[1]) + + def test_invalid_user_token(self): + invalid_token = "invalid_user_token" + resp = self.request( + "GET", + "/{model}", + auth=requests.auth.HTTPBasicAuth(self.db_name, invalid_token), + ) + self.assertEqual(resp.status_code, pinguin.CODE__no_user_auth[0]) + self.assertEqual(resp.json()["error"], pinguin.CODE__no_user_auth[1]) + + def test_user_not_allowed_for_namespace(self): + namespace = self.phantom_env["openapi.namespace"].search( + [("name", "=", "demo")] + ) + new_user = self.phantom_env["res.users"].create( + {"name": "new user", "login": "new_user"} + ) + new_user.write( + {"groups_id": [(4, self.phantom_env.ref("openapi.group_user").id)]} + ) + new_user.reset_openapi_token() + new_user.flush_recordset() + self.assertTrue(new_user.id not in namespace.user_ids.ids) + self.assertTrue(namespace.id not in new_user.namespace_ids.ids) + + resp = self.request_from_user(new_user, "GET", "/{model}") + self.assertEqual(resp.status_code, pinguin.CODE__user_no_perm[0], resp.json()) + self.assertEqual(resp.json()["error"], pinguin.CODE__user_no_perm[1]) + + def test_call_allowed_method_on_singleton_record(self): + if ( + not self.env["ir.module.module"].search([("name", "=", "mail")]).state + == "installed" + ): + self.skipTest( + "To run test 'test_call_allowed_method_on_singleton_record' install 'mail'-module" + ) + partner = self.phantom_env[self.model_name].search([], limit=1) + method_name = "message_post" + method_params = {"kwargs": {"body": MESSAGE}} + resp = self.request_from_user( + self.demo_user, + "PATCH", + "/{model}/{record_id}/call/{method_name}", + record_id=partner.id, + method_name=method_name, + data_json=method_params, + ) + self.assertEqual(resp.status_code, pinguin.CODE__success) + # TODO check that message is created + + def test_call_allowed_method_on_recordset(self): + partners = self.phantom_env[self.model_name].search([], limit=5) + method_name = "write" + method_params = { + "args": [{"name": "changed from write method called from api"}], + } + ids = partners.mapped("id") + ids_str = ",".join(str(i) for i in ids) + + resp = self.request_from_user( + self.demo_user, + "PATCH", + "/{model}/call/{method_name}/{ids}", + method_name=method_name, + ids=ids_str, + data_json=method_params, + ) + + self.assertEqual(resp.status_code, pinguin.CODE__success) + for i in range(len(partners)): + self.assertTrue(resp.json()[i]) + # reread records + partners = self.phantom_env[self.model_name].browse(ids) + for partner in partners: + self.assertEqual(partner.name, method_params["args"][0]["name"]) + + def test_call_model_method(self): + domain = [["id", "=", 1]] + record = self.phantom_env[self.model_name].search(domain) + self.assertTrue(record, "Record with ID 1 is not available") + + method_name = "search" + method_params = { + "args": [domain], + } + resp = self.request_from_user( + self.demo_user, + "PATCH", + "/{model}/call/{method_name}", + method_name=method_name, + data_json=method_params, + ) + + self.assertEqual(resp.status_code, pinguin.CODE__success) + self.assertEqual(resp.json(), [1]) + + # TODO: doesn't work in test environment + def _test_log_creating(self): + logs_count_before_request = len(self.phantom_env["openapi.log"].search([])) + self.request_from_user(self.demo_user, "GET", "/{model}") + logs_count_after_request = len(self.phantom_env["openapi.log"].search([])) + self.assertTrue(logs_count_after_request > logs_count_before_request) + + # TODO test is not update for the latest module version + def _test_get_report_for_allowed_model(self): + super_user = self.phantom_env.ref(USER_ADMIN) + modelname_for_report = "ir.module.module" + report_external_id = "base.ir_module_reference_print" + + model_for_report = self.phantom_env["ir.model"].search( + [("model", "=", modelname_for_report)] + ) + namespace = self.phantom_env["openapi.namespace"].search([("name", "=")]) + records_for_report = self.phantom_env[modelname_for_report].search([], limit=3) + docids = ",".join([str(i) for i in records_for_report.ids]) + + self.phantom_env["openapi.access"].create( + { + "active": True, + "namespace_id": namespace.id, + "model_id": model_for_report.id, + "model": modelname_for_report, + "api_create": False, + "api_read": True, + "api_update": False, + "api_public_methods": False, + "public_methods": False, + "private_methods": False, + "read_one_id": False, + "read_many_id": False, + "create_context_ids": False, + } + ) + + super_user.write({"namespace_ids": [(4, namespace.id)]}) + + url = "http://localhost:%d/api/v1/demo/report/html/%s/%s" % ( + config["http_port"], + report_external_id, + docids, + ) + resp = requests.request( + "GET", + url, + timeout=30, + auth=requests.auth.HTTPBasicAuth(self.db_name, super_user.openapi_token), + ) + self.assertEqual(resp.status_code, pinguin.CODE__success) + + def test_response_has_no_error(self): + method_name = "search_read" + method_params = { + "args": [[["id", "=", "1"]], ["id", "name"]], + } + resp = self.request_from_user( + self.demo_user, + "PATCH", + "/{model}/call/{method_name}", + method_name=method_name, + data_json=method_params, + ) + self.assertNotIn("error", resp.json()) diff --git a/openapi/tests/test_json_spec.py b/openapi/tests/test_json_spec.py new file mode 100644 index 00000000..6ce73bed --- /dev/null +++ b/openapi/tests/test_json_spec.py @@ -0,0 +1,37 @@ +# Copyright 2018-2019 Ivan Yelizariev +# Copyright 2018 Rafis Bikbov +# Copyright 2021 Denis Mudarisov +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import json + +from bravado_core.spec import Spec +from swagger_spec_validator import SwaggerValidationError + +from odoo.tests import tagged +from odoo.tests.common import HttpCase +from odoo.tools import config + + +@tagged("post_install", "-at_install") +class TestJsonSpec(HttpCase): + def test_json_base(self): + + resp = self.url_open( + "http://localhost:%d/api/v1/demo/swagger.json?token=demo_token&download" + % config["http_port"], + timeout=30, + ) + self.assertEqual(resp.status_code, 200, "Cannot get json spec") + # TODO add checking actual content of the json + + def test_OAS_scheme_for_demo_data_is_valid(self): + resp = self.url_open( + "http://localhost:%d/api/v1/demo/swagger.json?token=demo_token&download" + % config["http_port"], + timeout=30, + ) + spec_dict = json.loads(resp.content.decode("utf8")) + try: + Spec.from_dict(spec_dict, config={"validate_swagger_spec": True}) + except SwaggerValidationError as e: + self.fail("A JSON Schema for Swagger 2.0 is not valid:\n %s" % e) diff --git a/openapi/views/ir_model_view.xml b/openapi/views/ir_model_view.xml new file mode 100644 index 00000000..f3685710 --- /dev/null +++ b/openapi/views/ir_model_view.xml @@ -0,0 +1,26 @@ + + + + ir.model.form + ir.model + + + +
+ +
+
+
+
+
diff --git a/openapi/views/openapi_view.xml b/openapi/views/openapi_view.xml new file mode 100644 index 00000000..b4b1bdf3 --- /dev/null +++ b/openapi/views/openapi_view.xml @@ -0,0 +1,262 @@ + + + + + ir.exports.form + ir.exports + +
+ + + + + + + + + + + +
+ +
+
+ + logs.tree.view + openapi.log + + + + + + + + + + openapi.log.form + openapi.log + +
+ + + + + + + + +
+
+
+ + openapi.access.form + openapi.access + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+ +
+
+
+ + + +
+ + + + + +
+
+
+
+
+ +
+
+ + openapi.namespace.form + openapi.namespace + +
+
+
+ +
+ +
+ +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+ + openapi.namespace.tree + openapi.namespace + + + + + + + + openapi.access.tree + openapi.access + + + + + + + + + Integrations + openapi.namespace + tree,form + Create and manage the namespaces. + + + Accesses + openapi.access + tree,form + [('model_id', '=', active_id)] + {'default_model_id': active_id} + + + + +
diff --git a/openapi/views/res_users_view.xml b/openapi/views/res_users_view.xml new file mode 100644 index 00000000..9ba847b3 --- /dev/null +++ b/openapi/views/res_users_view.xml @@ -0,0 +1,28 @@ + + + + res.users.form.view + res.users + + 120 + +
+
+ + + + + + + + +
+
+