Использование API Odoo для удаленного вызова процедур (RPC)

Odoo API

Для того, чтобы взаимодейстовать удаленно с системой Odoo, с помощью RPC методов, нам необходимо в первую очередь получить session_id, которым мы будем подписывать каждый запрос к системе.

1. Получение информации о сессии

Ниже приведен пример на языке Python, я прокоментирую кажду строку, думаю все будет понятно.

# Переменная, которая хранит в себе путь к файлу с информацией о сессии
SESSION_FILE = "session.json"
# Адрес сервера, к которму мы планируем подключаться
HOST = 'www.адрес.сервера.by'
# Протокол, http или https
PROTOCOL = "http"
# Порт на котором слушает наш сервер, для https по умолчанию 443
PORT = "80"
# Объединяем все в одну строку
PROTOCOL_HOST_PORT = f"{PROTOCOL}://{HOST}:{PORT}"
# Указываем авторизационные данные, обратите внимание что тут три параметра, логи, пароль и имя базы данных
EMAIL = "admin"
PASSWORD = "admin"
DATABASE = "demo"

# Обращаю ваше внимание, это не JSON данные, а тип данных Dictionary(Словарь) в Python,
# но да, они очень похожи. При отправке вам нужно будет еще преобразовать их в
# JSON строку(байтовый массив) и закрепитьв  теле POST запроса
json_request_data = {"jsonrpc": "2.0","params":{
        "db":DATABASE,
        "login":EMAIL,
        "password":PASSWORD
    }
}

Обращаю ваше внимание, что запрос к Odoo должен быть оформлен по стандарту jsonrpc 2.0 Теперь подготовим запрос на получение авторазационного токена


#Объявлеям заколовки, которые сделают наш запрос json запросом
headers = {'Content-Type': 'application/json','Accept': 'text/plain'}
# тут мы создаем полный URL запроса
URL = PROTOCOL_HOST_PORT + "/web/session/authenticate"
# Делаем сам запрос,обратите внимание, что наш словарь с авторизационными данными, 
# мы преобразуем в строку с помощью метода json.dumps, т.е. на его менсто можно поставить обычную JSON строку
send_data = requests.post(URL,headers=headers,data=json.dumps(json_request_data))
# В этот момент нам может придти ответ двух видов
# Во первых там может быть информация об уже существующей сессии 
# от этого пользователя и в заголовке будет содержаться сообщение 
# вида session_id=1234567890000
# Поэтому если он там есть, мы его сразу извлекаем и подкидываем в качестве
# информации о сессии
set_cookies = send_data.headers["Set-Cookie"]
if "session_id" in set_cookies:
    session_id = set_cookies.split(";")[0].split("=")[1]
    return {"result":{"session_id":session_id}}
# Если же в заголовке нет информации о сессии, то из заголовка ответа "Set-Cookie" мы извлекаем authorization_token
authorization_token = set_cookies
# И подготавливаем заголовки для второго запроса, где в куку подсовываем наш authorization_token
headers = {'Cookie':authorization_token,'Content-Type': 'application/json','Accept': 'text/plain'}
# Обратите внимание на оформление запроса, в качестве тела запроса надо указать строку с пустыми {}
get_session_info = requests.post(PROTOCOL_HOST_PORT +'/web/session/get_session_info',headers=headers,data="{}")
# Полученный текст ответа и являет собой JSON строку с нужной нам информацией, и мы можем ее развернуть в
# объект для удобного взаимодействия с помощью метода json.loads. Но ничего не мешает работать 
# с ответом как с обычной строкой
session_info = json.loads(get_session_info.text)

Теперь давайте еще раз на словах

  1. Делаем JSONRPC 2.0 запрос на URI /web/session/authenticate
  2. Из ответа извлекаем содержимое заголовка Set-Cookie и это будет Авторизационный Токен или там будет сразу session_id=12345... и тогда мы сразу сохраняем его себе в кеш и пользуемся
  3. Подготавливаем второй JSONRPC 2.0 запрос на URI /web/session/get_session_info
  4. Добавляем к запросу заколовок Cookie и Авторизационный Токен в качестве его параметра
  5. Полученное тело ответа и будет JSON строка с информацией о сессии

2. Использование и проверка session_id

В ифнормации о сессии нас больше всего интересует ключ session_id. Собстенно это и будет нашим идентификатором, для подписи запросов. Наша сессия не бесконечна и ограничена 90 днями, плюс у нас могут возникнуть различные коллизии и session_id может стать не действительным. Чтобы убедиться в том, что наше идентификатор еще валиден, мы должны сделать следующее:

# Обратите внимание на оформление параметра 'Cookie', теперь его значение будет иметь примерно такоей вид
# 'Cookie': "session_id=250769263b42fb907b741608cc99ccdbeeac5940" 
headers = {'Cookie': "session_id="+session_id,'Content-Type': 'application/json','Accept': 'text/plain'}
# Делаем запрос на URI проверки валидности сессии
check_session = requests.post(PROTOCOL_HOST_PORT +'/web/session/check',headers=headers,data="{}")
# Полученную JSON строку парсим в объект
response = json.loads(check_session.text)
# Если в ответе есть ключ error, то кука не валидна, что мы и выводим, если такого ключа нет, то мы
# наша сессия валида
error = response.get('error',False)
if error:
    print(error.get("message"))
    return False
return True

Теперь помимо проверки session_id мы знаем как подписывать запрос к Odoo. Достаточно добавить соотвествующую куку к запросу

Я понимаю что с проверкой можйно не заморачиваться и ее наличие немного замедляет. Но если скрипт будет
каждый раз создавать сессию для своей работы, это тоже не корректно расходует его ресурсы.

3. Выполнение метода с помощью RPC

Для того, чтобы удаленно выполнить метод и получить результаты его работы в виде JSON строки, необходимо сделать следующее:

# Генерируем уникальный идентификатор запроса
req_uuid = uuid.uuid4()
json_rpc_data = {
    "jsonrpc": "2.0",
    "id": str(req_uuid),
    "method": "call",
    "params": {
            "model": "ir.default", # Имя модели, к которой хотим обратиться
            "method": "search_read", # Метод модели, который мы хотим вызвать для исполнения
            "args": [ # позиционный аргрументы для метода
                # ['id','parent_id','name']
            ],
            "kwargs":{ # ключевые аргументы для метода
                "domain":[
                    ('id','=',1),
                ],
                "fields": ['id','field_id','json_value'],
            }
        }
}
# Оформляем заголовки
headers = {'Cookie': "session_id="+session_id,'Content-Type': 'application/json','Accept': 'text/plain'}
# Делаем запрос на URI удаленного вызова процедур
method_result = requests.post(PROTOCOL_HOST_PORT +'/web/dataset/call_kw',headers=headers,data=json.dumps(json_rpc_data))
# Полученную JSON строку парсим в объект
response = json.loads(method_result.text)

4. Обработка ошибок

Что делать если возникают ошибки на самом сервера. Или как уведомить пользователя с клиентской стороны что он совершил явно не предусматриваемое действие? Для этого реализован механизм передачи возникающих исключений на сторону клиента. Вот как это реализовано. Ниже вы можете видеть пример ответа с ошибкой

{
    "jsonrpc": "2.0",
    "id": "73d5b198-066f-40fa-a52a-6c40e7e21551",
    "error": {
        "code": 200,
        "message": "Odoo Server Error",
        "data": {
            "name": "builtins.KeyError",
            "debug": "Traceback (most recent call last):\n  File \"/home/www-data/odoo/odoo/http.py\", line 653, in _handle_exception\n    return super(JsonRequest, self)._handle_exception(exception)\n  File \"/home/www-data/odoo/odoo/http.py\", line 312
, in _handle_exception\n    raise pycompat.reraise(type(exception), exception, sys.exc_info()[2])\n  File \"/home/www-data/odoo/odoo/tools/pycompat.py\", line 87, in reraise\n    raise value\n  File \"/home/www-data/odoo/odoo/http.py\", line 695, in disp
atch\n    result = self._call_function(**self.params)\n  File \"/home/www-data/odoo/odoo/http.py\", line 344, in _call_function\n    return checked_call(self.db, *args, **kwargs)\n  File \"/home/www-data/odoo/odoo/service/model.py\", line 97, in wrapper\
n    return f(dbname, *args, **kwargs)\n  File \"/home/www-data/odoo/odoo/http.py\", line 337, in checked_call\n    result = self.endpoint(*a, **kw)\n  File \"/home/www-data/odoo/odoo/http.py\", line 939, in __call__\n    return self.method(*args, **kw)\
n  File \"/home/www-data/odoo/odoo/http.py\", line 517, in response_wrap\n    response = f(*args, **kw)\n  File \"/home/www-data/odoo/addons/web/controllers/main.py\", line 934, in call_kw\n    return self._call_kw(model, method, args, kwargs)\n  File \"
/home/www-data/odoo/addons/web/controllers/main.py\", line 926, in _call_kw\n    return call_kw(request.env[model], method, args, kwargs)\n  File \"/home/www-data/odoo/odoo/api.py\", line 771, in __getitem__\n    return self.registry[model_name]._browse(
(), self)\n  File \"/home/www-data/odoo/odoo/modules/registry.py\", line 179, in __getitem__\n    return self.models[model_name]\nKeyError: 'stock.inventory2'\n",
            "message": "stock.inventory2",
            "arguments": [
                "stock.inventory2"
            ],
            "exception_type": "internal_error"
        }
    }
}

В случае возникновения ошибки, в ответе появляется ключ error, внутри которого вы сможете увидеть все необходимое для обработки ошибки:

  • code- код ответа сервера, соотвествуют кодам ответов HTTP сервера
  • message - краткое описание ошибки со стороны платформы
  • data - собственно данные по самой ошибке
    • name - имя ошибки
    • debug - полный трейлог ошибки
    • message - сообщение с описанием ошибки
    • arguments - список аргументов переданных в вознкшее исключение
    • exception_type - тип возникшего исключения

Чаще всего для пользователя самым информативным является поле message. Я рекомендую его показывать пользователям в случае возникновения ошибок. Данным механизмом разработчик серверной части может пользоваться для уведомления пользователей о неправильных действиях

Здесь вы можете ознакомться с полным скриптом
import requests
import json
import os
import io
import uuid

script_dir = os.path.dirname(os.path.abspath(__file__))
# Переменная, которая хранит в себе путь к файлу с информацией о сессии
SESSION_FILE = os.path.join(script_dir,"session.json")
# Адрес сервера, к которму мы планируем подключаться
HOST = 'www.адрес.сервера.by'
# Протокол, http или https
PROTOCOL = "http"
# Порт на котором слушает наш сервер, для https по умолчанию 443
PORT = "80"
# Объединяем все в одну строку
PROTOCOL_HOST_PORT = f"{PROTOCOL}://{HOST}:{PORT}"
# Указываем авторизационные данные, обратите внимание что тут три параметра, логи, пароль и имя базы данных
EMAIL = "admin"
PASSWORD = "admin"
DATABASE = "demo"

# Обращаю ваше внимание, это не JSON данные, а тип данных Dictionary(Словарь) в Python,
# но да, они очень похожи. При отправке вам нужно будет еще преобразовать их в
# JSON строку(байтовый массив) и закрепитьв  теле POST запроса
json_request_data = {"jsonrpc": "2.0","params":{
        "db":DATABASE,
        "login":EMAIL,
        "password":PASSWORD
    }
}

def get_session_info():
    #Объявлеям заколовки, которые сделают наш запрос json запросом
    headers = {'Content-Type': 'application/json','Accept': 'text/plain'}
    # тут мы создаем полный URL запроса
    URL = PROTOCOL_HOST_PORT + "/web/session/authenticate"
    # Делаем сам запрос,обратите внимание, что наш словарь с авторизационными данными, 
    # мы преобразуем в строку с помощью метода json.dumps, т.е. на его менсто можно поставить обычную JSON строку
    send_data = requests.post(URL,headers=headers,data=json.dumps(json_request_data))
    # В этот момент нам может придти ответ двух видов
    # Во первых там может быть информация об уже существующей сессии 
    # от этого пользователя и в заголовке будет содержаться сообщение 
    # вида session_id=1234567890000
    # Поэтому если он там есть, мы его сразу извлекаем и подкидываем в качестве
    # информации о сессии
    set_cookies = send_data.headers["Set-Cookie"]
    if "session_id" in set_cookies:
        session_id = set_cookies.split(";")[0].split("=")[1]
        return {"result":{"session_id":session_id}}
    # Если же в заголовке нет информации о сессии, то из заголовка ответа "Set-Cookie" мы извлекаем authorization_token
    authorization_token = set_cookies
    headers = {'Cookie':authorization_token,'Content-Type': 'application/json','Accept': 'text/plain'}
    # Обратите внимание на оформление запроса, в качестве тела запроса надо указать строку с пустыми {}
    get_session_info = requests.post(PROTOCOL_HOST_PORT +'/web/session/get_session_info',headers=headers,data="{}")
    # Полученный текст ответа и являет собой JSON строку с нужной нам информацией, и мы можем ее развернуть в
    # объект для удобного взаимодействия с помощью метода json.loads. Но ничего не мешает работать 
    # с ответом как с обычной строкой
    session_info = json.loads(get_session_info.text)
    return session_info


def check_if_session_is_valid(session_id):
    if session_id == 'fail':
        return False
    # Обратите внимание на оформление параметра 'Cookie', теперь его значение будет иметь примерно такоей вид
    # 'Cookie': "session_id=250769263b42fb907b741608cc99ccdbeeac5940" 
    headers = {'Cookie': "session_id="+session_id,'Content-Type': 'application/json','Accept': 'text/plain'}
    # Делаем запрос на URI проверки валидности сессии
    check_session = requests.post(PROTOCOL_HOST_PORT +'/web/session/check',headers=headers,data="{}")
    # Полученную JSON строку парсим в объект
    response = json.loads(check_session.text)
    # Если в ответе есть ключ error, то кука не валидна, что мы и выводим, если такого ключа нет, то мы
    # наша сессия валида
    error = response.get('error',False)
    if error:
        print(json.dumps(error.get('message'), indent=4,ensure_ascii=False))
        return False
    return True

def write_session_info_to_file(session_info,file_name):
    with io.open(file_name, 'w', encoding='utf8') as config_file:
        json.dump(session_info, config_file, indent=4)

validate_session_id = False
if os.path.isfile(SESSION_FILE):
    session_info = json.loads(open(SESSION_FILE).read())
    session_id = session_info.get("result",{}).get("session_id","fail")
    if check_if_session_is_valid(session_id):
        validate_session_id = session_id

if not validate_session_id or not os.path.isfile(SESSION_FILE):
    session_info = get_session_info()
    session_id = session_info.get("result",{}).get("session_id","fail")
    if check_if_session_is_valid(session_id):
        validate_session_id = session_id
        write_session_info_to_file(session_info,SESSION_FILE)

if validate_session_id:
    # Генерируем уникальный идентификатор запроса
    req_uuid = uuid.uuid4()
    json_rpc_data = {
        "jsonrpc": "2.0",
        "id": str(req_uuid),
        "method": "call",
        "params": {
                "model": "ir.default", # Имя модели, к которой хотим обратиться
                "method": "search_read", # Метод модели, который мы хотим вызвать для исполнения
                "args": [ # позиционный аргрументы для метода
                    # ['id','parent_id','name']
                ],
                "kwargs":{ # ключевые аргументы для метода
                    "domain":[
                        ('id','=',1),
                    ],
                    "fields": ['id','field_id','json_value'],
                }
            }
    }
    # Оформляем заголовки
    headers = {'Cookie': "session_id="+validate_session_id,'Content-Type': 'application/json','Accept': 'text/plain'}
    # Делаем запрос на URI удаленного вызова процедур
    method_result = requests.post(PROTOCOL_HOST_PORT +'/web/dataset/call_kw',headers=headers,data=json.dumps(json_rpc_data))
    # Полученную JSON строку парсим в объект
    response = json.loads(method_result.text)
    
    print(json.dumps(response, indent=4,ensure_ascii=False))

С помощью данного механизма мы можем выполнить любой метод в любой модели данных, к которым есть доступ у пользователя от имени которого мы запускаем выполнение