mirror of
https://gitlab.com/flectra-hq/flectra.git
synced 2026-11-02 09:58:50 -06:00
[ADD] new openapi modules
This commit is contained in:
1 parent
a7916efebd
commit
bd89cf9097
54 files changed
+5094
No files matched your search
@@ -0,0 +1,51 @@
|
||||
.. 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.
|
||||
|
||||
Credits
|
||||
=======
|
||||
|
||||
Contributors
|
||||
------------
|
||||
* `Anvar Kildebekov <https://it-projects.info/team/fedoranvar>`__:
|
||||
- :one::three: init version of the module
|
||||
|
||||
Sponsors
|
||||
--------
|
||||
* `IT-Projects LLC <https://it-projects.info>`__
|
||||
|
||||
Maintainers
|
||||
-----------
|
||||
* `IT-Projects LLC <https://it-projects.info>`__
|
||||
|
||||
To get a guaranteed support
|
||||
you are kindly requested to purchase the module
|
||||
at `odoo apps store <https://apps.odoo.com/apps/modules/13.0/base_api/>`__.
|
||||
|
||||
Thank you for understanding!
|
||||
|
||||
`IT-Projects Team <https://www.it-projects.info/team>`__
|
||||
|
||||
Further information
|
||||
===================
|
||||
|
||||
Demo: http://runbot.it-projects.info/demo/sync-addons/13.0
|
||||
|
||||
HTML Description: https://apps.odoo.com/apps/modules/13.0/base_api/
|
||||
|
||||
Usage instructions: `<doc/index.rst>`__
|
||||
|
||||
Changelog: `<doc/changelog.rst>`__
|
||||
|
||||
Notifications on updates: `via Atom
|
||||
<https://github.com/it-projects-llc/sync-addons/commits/13.0/base_api.atom>`__,
|
||||
`by Email
|
||||
<https://blogtrottr.com/?subscribe=https://github.com/it-projects-llc/sync-addons/commits/13.0/base_api.atom>`__
|
||||
|
||||
Tested on Odoo 13.0 eed3171f2f057223c3fbedd95ce6bcde2124f46c
|
||||
@@ -0,0 +1,4 @@
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
|
||||
|
||||
from . import models
|
||||
from . import lib
|
||||
@@ -0,0 +1,34 @@
|
||||
# Copyright 2019 Anvar Kildebekov <https://it-projects.info/team/fedoranvar>
|
||||
# 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",
|
||||
# "live_test_url": "http://apps.it-projects.info/shop/product/DEMO-URL?version=13.0",
|
||||
"version": "13.0.1.0.1",
|
||||
"application": False,
|
||||
"author": "FlectraHQ, IT-Projects LLC, Anvar Kildebekov",
|
||||
"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,
|
||||
# "demo_title": "Base API",
|
||||
# "demo_addons": [
|
||||
# ],
|
||||
# "demo_addons_hidden": [
|
||||
# ],
|
||||
# "demo_url": "DEMO-URL",
|
||||
# "demo_summary": "basic function and methods of API for openapi or XML-RPC",
|
||||
# "demo_images": [
|
||||
# "images/MAIN_IMAGE",
|
||||
# ]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
`1.0.1`
|
||||
-------
|
||||
- **Improvement:** Compatibility with python 3.9
|
||||
|
||||
`1.0.0`
|
||||
-------
|
||||
|
||||
- **Init version**
|
||||
@@ -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 <https://odoo-development.readthedocs.io/en/latest/dev/py/x2many.html>`__
|
||||
|
||||
*– 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 <https://odoo-development.readthedocs.io/en/latest/dev/py/x2many.html>`__,
|
||||
|
||||
- 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
|
||||
@@ -0,0 +1 @@
|
||||
from . import pinguin
|
||||
@@ -0,0 +1,359 @@
|
||||
# Copyright 2018, XOE Solutions
|
||||
# Copyright 2018-2019 Rafis Bikbov <https://it-projects.info/team/bikbov>
|
||||
# Copyright 2019 Yan Chirino <https://xoe.solutions/>
|
||||
# Copyright 2019-2020 Anvar Kildebekov <https://it-projects.info/team/fedoranvar>
|
||||
# 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:
|
||||
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))
|
||||
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,3 @@
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
|
||||
|
||||
from . import base
|
||||
@@ -0,0 +1,106 @@
|
||||
# Copyright 2019 Ivan Yelizariev <https://it-projects.info/team/yelizariev>
|
||||
# Copyright 2019 Anvar Kildebekov <https://it-projects.info/team/fedoranvar>
|
||||
# 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):
|
||||
try:
|
||||
result = imd_env.xmlid_lookup(PREFIX + "." + ext_id)[2]
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
"No object with external id in field {}: {}".format(field, ext_id)
|
||||
)
|
||||
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))
|
||||
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])
|
||||
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
|
||||
)
|
||||
vals[field][index] = tuple(list_record)
|
||||
|
||||
# If external id exists...
|
||||
try:
|
||||
inner_id = imd_env.xmlid_lookup(PREFIX + "." + ext_id)[2]
|
||||
# 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)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,96 @@
|
||||
<section class="oe_container">
|
||||
<div class="oe_row oe_spaced">
|
||||
<div class="oe_span12">
|
||||
<h2 class="oe_slogan" style="color:#875A7B;">Base API</h2>
|
||||
<h3 class="oe_slogan">Improve your API experience</h3>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="oe_container">
|
||||
<div class="oe_row oe_spaced">
|
||||
<div class="oe_span12">
|
||||
|
||||
<div class="alert alert-info oe_mt32" style="padding:0.3em 0.6em; font-size: 150%;">
|
||||
<i class="fa fa-hand-o-right"></i><b> Key features: </b>
|
||||
<ul class="list-unstyled">
|
||||
|
||||
<li>
|
||||
<i class="fa fa-check-square-o text-primary"></i>
|
||||
Eliminate race hazard on records creation
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<i class="fa fa-check-square-o text-primary"></i>
|
||||
Nested data receiving in a single API request
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<i class="fa fa-check-square-o text-primary"></i>
|
||||
Painless work with <em>External Identifiers</em>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="oe_container">
|
||||
<div class="oe_row oe_spaced">
|
||||
<div class="oe_span8">
|
||||
<h2>Need our service?</h2>
|
||||
<p class="oe_mt32">Contact us by <a href="mailto:apps@it-projects.info">email</a> or fill out <a
|
||||
href="https://www.it-projects.info/page/website.contactus " target="_blank">request form</a></p>
|
||||
<ul>
|
||||
<li><a href="mailto:apps@it-projects.info">apps@it-projects.info <i class="fa fa-envelope-o"></i></a>
|
||||
</li>
|
||||
<li><a href="https://www.it-projects.info/page/website.contactus "
|
||||
target="_blank">https://www.it-projects.info/page/website.contactus <i
|
||||
class="fa fa-list-alt"></i></a></li>
|
||||
<li><a href="https://m.me/itprojectsllc" target="_blank">https://m.me/itprojectsllc <i
|
||||
class="fa fa-facebook-square"></i></a></li>
|
||||
<li>skype@it-projects.info <i class="fa fa-skype"></i></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="oe_span4">
|
||||
<div class="stamp" style="width:200px;">
|
||||
<div style="margin-top: 15px;
|
||||
position: relative;
|
||||
font-family:'Vollkorn', serif;
|
||||
font-size: 16px;
|
||||
line-height: 25px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: #75526b;
|
||||
border: 3px dashed #75526b;
|
||||
float: left;
|
||||
padding: 4px 12px;
|
||||
-webkit-transform: rotate(-6deg);
|
||||
-o-transform: rotate(-6deg);
|
||||
-moz-transform: rotate(-6deg);
|
||||
-ms-transform: rotate(-6deg);">
|
||||
Tested on Odoo<br />13.0 community
|
||||
</div>
|
||||
<!--<div style="margin-top: 15px;
|
||||
position: relative;
|
||||
font-family:'Vollkorn', serif;
|
||||
font-size: 16px;
|
||||
line-height: 25px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: #75526b;
|
||||
border: 3px dashed #75526b;
|
||||
float: left;
|
||||
padding: 4px 12px;
|
||||
-webkit-transform: rotate(9deg);
|
||||
-o-transform: rotate(9deg);
|
||||
-moz-transform: rotate(9deg);
|
||||
-ms-transform: rotate(9deg);">
|
||||
Tested on Odoo<br/>13.0 enterprise
|
||||
</div>-->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,3 @@
|
||||
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html)
|
||||
|
||||
from . import test_base
|
||||
@@ -0,0 +1,246 @@
|
||||
# Copyright 2019 Ivan Yelizariev <https://it-projects.info/team/yelizariev>
|
||||
# Copyright 2019 Anvar Kildebekov <https://it-projects.info/team/fedoranvar>
|
||||
# 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("post_install", "at_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])
|
||||
#
|
||||
# unlink test objects
|
||||
#
|
||||
partner_obj.browse(record_ids[0]).unlink()
|
||||
partner_obj.browse(record_ids2[0]).unlink()
|
||||
partner_obj.browse(record_ids3[0]).unlink()
|
||||
partner_obj.browse(record_ids4[0]).unlink()
|
||||
|
||||
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"})
|
||||
t_country_2 = country_obj.create({"name": "TestCountry2"})
|
||||
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)
|
||||
#
|
||||
# Unlink test objects
|
||||
#
|
||||
t_partner_1.unlink()
|
||||
t_partner_2.unlink()
|
||||
t_company_1.write({"country_id": [(5, 0, 0)]})
|
||||
t_company_2.write({"country_id": [(5, 0, 0)]})
|
||||
t_company_1.unlink()
|
||||
t_company_2.unlink()
|
||||
t_category_1.unlink()
|
||||
t_category_2.unlink()
|
||||
t_category_3.unlink()
|
||||
t_category_4.unlink()
|
||||
t_country_1.unlink()
|
||||
t_country_2.unlink()
|
||||
|
||||
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])
|
||||
#
|
||||
# Unlink test objects
|
||||
#
|
||||
|
||||
partner_obj.browse(record_id2).unlink()
|
||||
partner_obj.browse(record_id3).unlink()
|
||||
t_company.unlink()
|
||||
t_child_1.unlink()
|
||||
t_child_2.unlink()
|
||||
Reference in new issue
Block a user