Building Interface Extensions - Odoo 8.0

Это руководство посвящено созданию модулей для веб-клиента Odoo.

Чтобы создать веб-сайты с помощью Odoo, см. Создание веб-сайта; того чтобы добавить новые бизнес возможности или расширить существующие в Odoo, см. Создание модуля.

Простой модуль

Начнем с простого модуля Odoo, содержащего базовую конфигурацию веб-компонентов и позволяющую протестировать веб-фреймворк.

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

$ git clone http://github.com/odoo/petstore

This will create a petstore folder wherever you executed the command. You then need to add that folder to Odoo's addons path, create a new database and install the oepetstore module.

Если вы просмотрите папку petstore, увидите следующие содержимое:

oepetstore
|-- images
|   |-- alligator.jpg
|   |-- ball.jpg
|   |-- crazy_circle.jpg
|   |-- fish.jpg
|   `-- mice.jpg
|-- __init__.py
|-- oepetstore.message_of_the_day.csv
|-- __openerp__.py
|-- petstore_data.xml
|-- petstore.py
|-- petstore.xml
`-- static
    `-- src
        |-- css
        |   `-- petstore.css
        |-- js
        |   `-- petstore.js
        `-- xml
            `-- petstore.xml

Модуль уже содержит некоторые изменения серверной части. Мы вернемся к ним позже, а пока давайте сосредоточимся на веб-содержимом в папке static.

Файлы используемые в "web" части Odoo модуля должны быть помещены в каталог static, для того, чтобы они стали доступны из веб-браузереа файлы, находящиеся вне этого каталога, не могут быть получены браузером напрямую. Подкаталоги src/css, src/js и src/xml не обязаны иметь строго аналогичные названия и имеют такие имена исключительно для удобства разработки.

oepetstore/static/css/petstore.css
currently empty, will hold the CSS for pet store content
oepetstore/static/xml/petstore.xml

Так же почти пуст, будет содержать QWeb шаблон

oepetstore/static/js/petstore.js

Наиболее важная (и интересная) часть содержит логику приложения (или, по крайней мере, его веб-браузерную часть) в виде javascript. Должна выглядеть следующим образом:

openerp.oepetstore = function(instance, local) {
    var _t = instance.web._t,
        _lt = instance.web._lt;
    var QWeb = instance.web.qweb;

    local.HomePage = instance.Widget.extend({
        start: function() {
            console.log("pet store home page loaded");
        },
    });

    instance.web.client_actions.add(
        'petstore.homepage', 'instance.oepetstore.HomePage');
}

Этот код выводит небольшое сообщение в консоль веб-браузера.

Odoo JavaScript модуль

Чистый Javascript сам по себе не имеет встроенных модулей. В результате чего переменные определенные в разных файлах могут смешиваться и конфликтовать. Такая ситуация привела к тому, что стали появляться различные шаблоны модулей, использование которых позволяет создавать чистые пространства имен с минимальными рисками конфликтов.

Odoo использует такого рода шаблоны для определения модулей внутри своих веб расширений, для упорядочивания как пространства имен так и правильного порядка загрузки.

oepetstore/static/js/petstore.js содержит объявление модуля:

openerp.oepetstore = function(instance, local) {
    local.xxx = ...;
}

In Odoo web, modules are declared as functions set on the global openerp variable. The function's name must be the same as the addon (in this case oepetstore) so the framework can find it, and automatically initialize it.

Когда веб-клиент загружает ваш модуль, он вызывает корневую функцию и предоставляет ей два параметра:

  • Первый параметр является текущим экземпляром веб-клиента Odoo, он предоставляет доступ к различным возможностям, определенным Odoo (переводы, сетевые службы), а также объекты, определенные ядром или другими модулями.

  • Вторым параметром является ваше собственное локальное пространство имен, автоматически созданное веб-клиентом. Объекты и переменные, которые должны быть доступны извне вашего модуля (либо из-за того что Odoo веб клиенту необходимо вызывать их, либо другие хотят кастомизировать их) должны быть установлены внутри этого пространства имен.

Классы

В отличие от модулей и вопреки большинству объектно-ориентированных языков, javascript не строит в классах [#classes] _ хотя он предоставляет примерно эквивалентные (более низкоуровневые и более многословные) механизмы.

Для простоты и удобства разработчиков веб часть Odoo предоставляет систему классов основанную на Simple JavaScript Inheritance.

New classes are defined by calling the extend() method of openerp.web.Class():

var MyClass = instance.web.Class.extend({
    say_hello: function() {
        console.log("hello");
    },
});

The extend() method takes a dictionary describing the new class's content (methods and static attributes). In this case, it will only have a say_hello method which takes no parameters.

Классы создаются с помощью оператора new:

var my_object = new MyClass();
my_object.say_hello();
// print "hello" in the console

Доступ к атрибутам экземпляра может быть получен через this:

var MyClass = instance.web.Class.extend({
    say_hello: function() {
        console.log("hello", this.name);
    },
});

var my_object = new MyClass();
my_object.name = "Bob";
my_object.say_hello();
// print "hello Bob" in the console

Классы могут предоставлять инициализатор для выполнения первоначальной настройки экземпляра, через определение метода init(). Инициализатор принимает параметры, переданы при использовании оператора new:

var MyClass = instance.web.Class.extend({
    init: function(name) {
        this.name = name;
    },
    say_hello: function() {
        console.log("hello", this.name);
    },
});

var my_object = new MyClass("Bob");
my_object.say_hello();
// print "hello Bob" in the console

It is also possible to create subclasses from existing (used-defined) classes by calling extend() on the parent class, as is done to subclass Class():

var MySpanishClass = MyClass.extend({
    say_hello: function() {
        console.log("hola", this.name);
    },
});

var my_object = new MySpanishClass("Bob");
my_object.say_hello();
// print "hola Bob" in the console

При переопределении метода с использованием наследования вы можете использовать this._super() для вызова исходного метода:

var MySpanishClass = MyClass.extend({
    say_hello: function() {
        this._super();
        console.log("translation in Spanish: hola", this.name);
    },
});

var my_object = new MySpanishClass("Bob");
my_object.say_hello();
// print "hello Bob \n translation in Spanish: hola Bob" in the console

Основы работы с Widget

Web-клиент Odoo связывает jQuery для легкой манипуляции с DOM. Это полезно и обеспечивает лучший API, чем стандартный W3C DOM [#dombugs]_, которого не достаточно для структурирования комплексных приложений, что приводит к сложностям в обслуживании.

Much like object-oriented desktop UI toolkits (e.g. Qt, Cocoa or GTK), Odoo Web makes specific components responsible for sections of a page. In Odoo web, the base for such components is the Widget() class, a component specialized in handling a page section and displaying information for the user.

Ваш первый Widget

В начальном демонстрационном модуле уже есть базовый виджет:

local.HomePage = instance.Widget.extend({
    start: function() {
        console.log("pet store home page loaded");
    },
});

It extends Widget() and overrides the standard method start(), which — much like the previous MyClass — does little for now.

Эта строка в конце файла:

instance.web.client_actions.add(
    'petstore.homepage', 'instance.oepetstore.HomePage');

Регистрирует наш основной виджет как действие клиента. Действия клиента будут объяснены позже, на данный момент это именно то, что позволяет нашему виджету вызываться и отображаться, когда мы выбираем меню Pet Store ‣ Pet Store ‣ Home Page.

Отображение контента

Виджеты имеют ряд методов и функций, но основы просты:

  • настройка виджета

  • формат данных виджета

  • отображение виджета

The HomePage widget already has a start() method. That method is part of the normal widget lifecycle and automatically called once the widget is inserted in the page. We can use it to display some content.

All widgets have a $el which represents the section of page they're in charge of (as a jQuery object). Widget content should be inserted there. By default, $el is an empty <div> element.

Элемент <div> `` обычно невидим для пользователя, если внутри нет контента (или нет определенных стилей, придающих ему размер), поэтому при запуске ``HomePage пока на странице ничего не отобразиться.

Давайте добавим содержимое в корневой элемент виджета, используя jQuery:

local.HomePage = instance.Widget.extend({
    start: function() {
        this.$el.append("<div>Hello dear Odoo user!</div>");
    },
});

Это сообщение появится, когда вы откроете Pet Store ‣ Pet Store ‣ Home Page

Виджет HomePage используется Odoo Web и управляется автоматически. Чтобы узнать, как работать с виджетами «с нуля», давайте создадим новый:

local.GreetingsWidget = instance.Widget.extend({
    start: function() {
        this.$el.append("<div>We are so happy to see you again in this menu!</div>");
    },
});

We can now add our GreetingsWidget to the HomePage by using the GreetingsWidget's appendTo() method:

local.HomePage = instance.Widget.extend({
    start: function() {
        this.$el.append("<div>Hello dear Odoo user!</div>");
        var greeting = new local.GreetingsWidget(this);
        return greeting.appendTo(this.$el);
    },
});
  • HomePage сначала добавляет свое содержимое в корень DOM

  • Затем ` HomePage`` создает экземпляр GreetingsWidget

  • Finally it tells GreetingsWidget where to insert itself, delegating part of its $el to the GreetingsWidget.

When the appendTo() method is called, it asks the widget to insert itself at the specified position and to display its content. The start() method will be called during the call to appendTo().

To see what happens under the displayed interface, we will use the browser's DOM Explorer. But first let's alter our widgets slightly so we can more easily find where they are, by adding a class to their root elements:

local.HomePage = instance.Widget.extend({
    className: 'oe_petstore_homepage',
    ...
});
local.GreetingsWidget = instance.Widget.extend({
    className: 'oe_petstore_greetings',
    ...
});

Если вы можете найти соответствующий раздел DOM (щелкните правой кнопкой мыши на странице, затем Inspect Element), он должен выглядеть так:

<div class="oe_petstore_homepage">
    <div>Hello dear Odoo user!</div>
    <div class="oe_petstore_greetings">
        <div>We are so happy to see you again in this menu!</div>
    </div>
</div>

Which clearly shows the two <div> elements automatically created by Widget(), because we added some classes on them.

Мы также можем увидеть два прикрепленных к сообщению div'а, которые мы добавили сами.

Наконец, обратите внимание, что элемент <div class="oe_petstore_greetings">, который представляет экземпляр GreetingsWidget находящийся внутри <div class="oe_petstore_homepage"> предоставленного экземпляра HomePage.

Родительские и дочерние объекты Widget

В предыдущей части мы создали экземпляр виджета, используя этот синтаксис:

new local.GreetingsWidget(this);

Первый аргумент - this`, который в этом случае является экземпляром ``HomePage. Это говорит виджету созданному другим виджетом, что он является его родителем.

Как мы видели, виджеты, как правило, вставляются в DOM другим виджетами, которые будут являться корневым элементом вставляемого виджета. Это означает, что большинство виджетов являются «частью» другого виджета и существуют от его имени. Мы вызываем контейнер parent, а содержащий его виджет child.

Из-за многочисленных технических и концептуальных причин виджету необходимо знать, кто его родитель и кто его дети.

getParent()

Может использоваться для получения родителя виджета:

local.GreetingsWidget = instance.Widget.extend({
    start: function() {
        console.log(this.getParent().$el );
        // will print "div.oe_petstore_homepage" in the console
    },
});
getChildren()

Может использоваться для получения списка его дочерних объектов:

local.HomePage = instance.Widget.extend({
    start: function() {
        var greeting = new local.GreetingsWidget(this);
        greeting.appendTo(this.$el);
        console.log(this.getChildren()[0].$el);
        // will print "div.oe_petstore_greetings" in the console
    },
});

When overriding the init() method of a widget it is of the utmost importance to pass the parent to the this._super() call, otherwise the relation will not be set up correctly:

local.GreetingsWidget = instance.Widget.extend({
    init: function(parent, name) {
        this._super(parent);
        this.name = name;
    },
});

Наконец, если виджет не имеет родительского объекта (т.к. это корневой виджет приложения), в качестве родителя может быть указан null:

new local.GreetingsWidget(null);

Уничтожение виджетов

If you can display content to your users, you should also be able to erase it. This is done via the destroy() method:

greeting.destroy();

When a widget is destroyed it will first call destroy() on all its children. Then it erases itself from the DOM. If you have set up permanent structures in init() or start() which must be explicitly cleaned up (because the garbage collector will not handle them), you can override destroy().

Шаблонизатор QWeb

В предыдущем разделе мы добавляли контент к нашим виджетам, непосредственно манипулируя (и добавляя) ими в DOM:

this.$el.append("<div>Hello dear Odoo user!</div>");

Это позволяет генерировать и отображать любой тип контента, но становится громоздким при генерации значительных объемов DOM (много повторяющихся действий, проблемы с цитированием, ...)

Как и во многих других средах, решение Odoo заключается в использовании template engine. Шаблонизатор Odoo называется QWeb.

QWeb - это язык шаблонов на основе XML, подобный Genshi, Thymeleaf or Facelets. Он имеет следующие характеристики:

  • Он полностью написан на JavaScript и рендерится в браузере

  • Каждый файл шаблона (файлы XML) может содержать несколько шаблонов

  • It has special support in Odoo Web's Widget(), though it can be used outside of Odoo's web client (and it's possible to use Widget() without relying on QWeb)

Использование QWeb

Для начала давайте определим простой QWeb шаблон в почти пустом файле``oepetstore/static/src/xml/petstore.xml``:

<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
    <t t-name="HomePageTemplate">
        <div style="background-color: red;">This is some simple HTML</div>
    </t>
</templates>

Теперь мы можем использовать этот шаблон внутри виджета HomePage. Используя переменную загрузчика QWeb, определенную в верхней части страницы, мы можем вызвать шаблон, определенный в файле XML:

local.HomePage = instance.Widget.extend({
    start: function() {
        this.$el.append(QWeb.render("HomePageTemplate"));
    },
});

QWeb.render() ищет определенный шаблон преобразовывает в строку и возвращает результат.

However, because Widget() has special integration for QWeb the template can be set directly on the widget via its template attribute:

local.HomePage = instance.Widget.extend({
    template: "HomePageTemplate",
    start: function() {
        ...
    },
});

Хотя результат выглядит похожим, между этими подходами есть два различия:

  • with the second version, the template is rendered right before start() is called
  • в первом варианте содержимое шаблона добавляется к корневому элементу виджета, тогда как во втором варианте корневой элемент шаблона сразу устанавливается, как корневой элемент виджета. Вот почему вспомогательный виджет "greetings" sub-widget также получает красный фон

Работа с Context в Qweb

Шаблонам QWeb могут быть предоставлены данные и они могут содержать базовую логику отображения.

Для явных вызовов QWeb.render(), данные шаблона передаются как второй параметр:

QWeb.render("HomePageTemplate", {name: "Klaus"});

С измененным шаблоном:

<t t-name="HomePageTemplate">
    <div>Hello <t t-esc="name"/></div>
</t>

даст результат:

<div>Hello Klaus</div>

When using Widget()'s integration it is not possible to provide additional data to the template. The template will be given a single widget context variable, referencing the widget being rendered right before start() is called (the widget's state will essentially be that set up by init()):

<t t-name="HomePageTemplate">
    <div>Hello <t t-esc="widget.name"/></div>
</t>
local.HomePage = instance.Widget.extend({
    template: "HomePageTemplate",
    init: function(parent) {
        this._super(parent);
        this.name = "Mordecai";
    },
    start: function() {
    },
});

Результат

<div>Hello Mordecai</div>

Объявление шаблона

Мы увидели как происходит визуализация шаблона QWeb, теперь давайте посмотрим синтаксис самих шаблонов

Шаблон QWeb состоит из обычного XML, смешанного с директивами QWeb. Директива QWeb объявляется с атрибутами XML, начинающимися с t-.

Самая простая директива это t-name, используемая для объявления новых шаблонов используемых в файле:

<templates>
    <t t-name="HomePageTemplate">
        <div>This is some simple HTML</div>
    </t>
</templates>

t-name принимает имя определяемого шаблона и объявляет таким образом, что его можно вызвать с помощью QWeb.render(). Его можно использовать только на верхнем уровне файла шаблона.

Вывод данных

Директива t-esc может использоваться для вывода текста:

<div>Hello <t t-esc="name"/></div>

Он принимает вычисляемое выражение Javascript, а затем его результат помещается в HTML-код и вставляется в документ. Поскольку это выражение, то можно указать только имя переменной, как показано выше, или более сложное выражение, например какое либо вычисление:

<div><t t-esc="3+5"/></div>

или вызов метода:

<div><t t-esc="name.toUpperCase()"/></div>

Вывод HTML

Чтобы внедрить HTML-код на отображаемую страницу, используйте t-raw. Подобно t-esc он принимает произвольное Javascript-выражение как параметр, но не выполняет вывод HTML.

<div><t t-raw="name.link(user_account)"/></div>

Условия

QWeb может использовать условия используя блок t-if. Директива принимает произвольное выражение, если выражение не существует (false, null, 0 или пустая строка) блок не выводиться, в противном случае он отображается.

<div>
    <t t-if="true == true">
        true is true
    </t>
    <t t-if="true == false">
        true is not true
    </t>
</div>

Перебор значений

Для перебора значений используется t-foreach и t-as. t-foreach принимает выражение, возвращающее список для перебора, t-as - это имя переменной для привязки к каждому элементу во время перебора.

<div>
    <t t-foreach="names" t-as="name">
        <div>
            Hello <t t-esc="name"/>
        </div>
    </t>
</div>

Определение атрибутов

QWeb предоставляет две связанные директивы для определения атрибутов HTML: t-att-name и t-attf-name. В любом случае, name это имя создаваемого атрибута (например t-att-id определяет атрибут id после визуализации)

t-att- принимает выражение javascript, результат которого устанавливается как значение атрибута. Эти директивы наиболее полезны, если все значение атрибута вычисляемые:

<div>
    Input your name:
    <input type="text" t-att-value="defaultName"/>
</div>

t-attf- принимает строковый формат. Строковый формат это текст с интерполяционными блоками. Интерполяционный блок - это javascript-выражение между {{ и }}, который будет заменен результатом выражения. Это особенно полезно для атрибутов, которые частично имеют текстовые значения и частично вычисляемые, например класс:

<div t-attf-class="container {{ left ? 'text-left' : '' }} {{ extra_class }}">
    insert content here
</div>

Вызов других шаблонов

Шаблоны можно разбить на подшаблоны (для простоты, управляемости, многократного использования или во избежание чрезмерной перегрузки разметки)

Это реализовано с помощью t-call директивы, которая принимает имя шаблона для визуализации:

<t t-name="A">
    <div class="i-am-a">
        <t t-call="B"/>
    </div>
</t>
<t t-name="B">
    <div class="i-am-b"/>
</t>

визуализация A шаблона даст результат:

<div class="i-am-a">
    <div class="i-am-b"/>
</div>

Суб-шаблоны наследуют контекст при визуализации вызывающего их объекта.

Для более глубокого изучения QWeb

Для подробной справки по QWeb смотрите QWeb.

Упражнение

Помощники для Widget

Widget's jQuery Selector

Выбор элементов DOM внутри виджетов можно выполнить, вызвав метод find() в корне DOM виджета:

this.$el.find("input.my_input")...

But because it's a common operation, Widget() provides an equivalent shortcut through the $() method:

local.MyWidget = instance.Widget.extend({
    start: function() {
        this.$("input.my_input")...
    },
});

Простое связывание DOM событий

Ранее мы связывали события DOM с помощью обычных обработчиков событий jQuery (например .click () или .change ()) на элементах виджета:

local.MyWidget = instance.Widget.extend({
    start: function() {
        var self = this;
        this.$(".my_button").click(function() {
            self.button_clicked();
        });
    },
    button_clicked: function() {
        ..
    },
});

Хотя это метод и работает, у него есть несколько проблем:

  1. он довольно многословный

  2. он не поддерживает замену корневого элемента виджета во время выполнения, поскольку привязка выполняется только при запуске start() (во время инициализации виджета)

  3. он требует решения this-связанных проблем

Widgets thus provide a shortcut to DOM event binding via events:

local.MyWidget = instance.Widget.extend({
    events: {
        "click .my_button": "button_clicked",
    },
    button_clicked: function() {
        ..
    }
});

events is an object (mapping) of an event to the function or method to call when the event is triggered:

  • ключ - это имя события, которое может быть уточнено с помощью селектора CSS, и в этом случае, только если событие произойдет в выбранном подэлементе, будет выполняться функция или метод: click будет обрабатывать все клики внутри виджета, а click .my_button будет обрабатывать только клики в элементах с классом my_button

  • Значение - это действие, которое должно выполняться при срабатывании события

    Это может быть либо функция:

    events: {
        'click': function (e) { /* code here */ }
    }
    

    Или имя метода объекта (см. Пример выше).

    В любом случае this является экземпляром виджета, а обработчику задан единственный параметр - jQuery event object для события.

События и свойства Widget

События

Виджеты предоставляют систему событий (свою, отдельно от описанной выше систем событий DOM/jQuery): виджет может сам запускать события, а другие виджеты (или сам) могут, при настройке связи, прослушивать эти события:

local.ConfirmWidget = instance.Widget.extend({
    events: {
        'click button.ok_button': function () {
            this.trigger('user_chose', true);
        },
        'click button.cancel_button': function () {
            this.trigger('user_chose', false);
        }
    },
    start: function() {
        this.$el.append("<div>Are you sure you want to perform this action?</div>" +
            "<button class='ok_button'>Ok</button>" +
            "<button class='cancel_button'>Cancel</button>");
    },
});

Этот виджет виден, он преобразует входные данные пользователя (через события DOM) во внутреннее событие для родительских виджетов.

trigger() takes the name of the event to trigger as its first (mandatory) argument, any further arguments are treated as event data and passed directly to listeners.

We can then set up a parent event instantiating our generic widget and listening to the user_chose event using on():

local.HomePage = instance.Widget.extend({
    start: function() {
        var widget = new local.ConfirmWidget(this);
        widget.on("user_chose", this, this.user_chose);
        widget.appendTo(this.$el);
    },
    user_chose: function(confirm) {
        if (confirm) {
            console.log("The user agreed to continue");
        } else {
            console.log("The user refused to continue");
        }
    },
});

on() binds a function to be called when the event identified by event_name is. The func argument is the function to call and object is the object to which that function is related if it is a method. The bound function will be called with the additional arguments of trigger() if it has any. Example:

start: function() {
    var widget = ...
    widget.on("my_event", this, this.my_event_triggered);
    widget.trigger("my_event", 1, 2, 3);
},
my_event_triggered: function(a, b, c) {
    console.log(a, b, c);
    // will print "1 2 3"
}

Свойства

Свойства очень похожи на обычные атрибуты объектов, поскольку они позволяют хранить данные в экземпляре виджетов, однако у них есть дополнительная функция, при которой они запускают события при установке:

start: function() {
    this.widget = ...
    this.widget.on("change:name", this, this.name_changed);
    this.widget.set("name", "Nicolas");
},
name_changed: function() {
    console.log("The new value of the property 'name' is", this.widget.get("name"));
}
  • set() sets the value of a property and triggers change:propname (where propname is the property name passed as first parameter to set()) and change
  • get() retrieves the value of a property.

Упражнение

Изменение существующих виджетов и классов

The class system of the Odoo web framework allows direct modification of existing classes using the include() method:

var TestClass = instance.web.Class.extend({
    testMethod: function() {
        return "hello";
    },
});

TestClass.include({
    testMethod: function() {
        return this._super() + " world";
    },
});

console.log(new TestClass().testMethod());
// will print "hello world"

Эта система похожа на механизм наследования, за исключением того, что она изменит целевой класс на месте вместо создания нового класса.

In that case, this._super() will call the original implementation of a method being replaced/redefined. If the class already had sub-classes, all calls to this._super() in sub-classes will call the new implementations defined in the call to include(). This will also work if some instances of the class (or of any of its sub-classes) were created prior to the call to include().

Система переводов на другие языки

Процесс преобразования текста в код Python и JavaScript очень похож. Вы могли заметить эти строки в начале файла petstore.js:

var _t = instance.web._t,
    _lt = instance.web._lt;

Эти строки просто используются для импорта функций перевода в текущий модуль JavaScript. Они используются следующим образом:

this.$el.text(_t("Hello user!"));

In Odoo, translations files are automatically generated by scanning the source code. All piece of code that calls a certain function are detected and their content is added to a translation file that will then be sent to the translators. In Python, the function is _(). In JavaScript the function is _t() (and also _lt()).

Функция _t() возвращает перевод, определенный для текста, который задан. Если для этого текста не определен перевод, то она вернет исходный текст как есть.

_lt() ("lazy translate") is similar but somewhat more complex: instead of translating its parameter immediately, it returns an object which, when converted to a string, will perform the translation.

Данный механизм используется для определения переводимых терминов до инициализации системы переводов, например, для атрибутов класса (поскольку модули загружаются до того, как язык пользователя настроен и загружены переводы).

Связь с сервером Odoo

Взаимодействие с моделями данных

Большинство операций с Odoo связаны со взаимодействием с моделями данных, реализующими бизнес-логику, и эти модели данных будут (потенциально) взаимодействовать с какой либо системой хранения (обычно PostgreSQL).

Хотя jQuery предоставляет функцию $.ajax для сетевых взаимодействий, для связи с Odoo требуются дополнительные метаданные, настройка которых перед каждым вызовом будет многословной и подверженной ошибкам. В результате, веб клиент Odoo предоставляет примитивы связей более высокого уровня.

Чтобы продемонстрировать это, файл petstore.py уже содержит небольшую модель с образцом:

class message_of_the_day(models.Model):
    _name = "oepetstore.message_of_the_day"

    @api.model
    def my_method(self):
        return {"hello": "world"}

    message = fields.Text(),
    color = fields.Char(size=20),

В этом примере объявлена модель данных с двумя полями и методом my_method (), который возвращает словарь литералов.

Вот пример виджета, который вызывает my_method () и отобразит результат:

local.HomePage = instance.Widget.extend({
    start: function() {
        var self = this;
        var model = new instance.web.Model("oepetstore.message_of_the_day");
        model.call("my_method", {context: new instance.web.CompoundContext()}).then(function(result) {
            self.$el.append("<div>Hello " + result["hello"] + "</div>");
            // will show "Hello world" to the user
        });
    },
});

The class used to call Odoo models is openerp.Model(). It is instantiated with the Odoo model's name as first parameter (oepetstore.message_of_the_day here).

call() can be used to call any (public) method of an Odoo model. It takes the following positional arguments:

name

Имя вызываемого метода (в нашем случае my_method)

args

массив positional arguments для предоставления метода. Поскольку пример не имеет позиционного аргумента, параметр args не предоставляется.

Вот другой пример с позиционными аргументами:

@api.model
def my_method2(self, a, b, c): ...
model.call("my_method", [1, 2, 3], ...
// with this a=1, b=2 and c=3
kwargs

Отображение keyword arguments для передачи. В примере представлен одиночный аргумент context.

@api.model
def my_method2(self, a, b, c): ...
model.call("my_method", [], {a: 1, b: 2, c: 3, ...
// with this a=1, b=2 and c=3

call() returns a deferred resolved with the value returned by the model's method as first argument.

CompoundContext - составной контекст

В предыдущем разделе использовался аргумент context, который не был объяснен в вызове метода:

model.call("my_method", {context: new instance.web.CompoundContext()})

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

The argument is necessary in all methods, otherwise bad things could happen (such as the application not being translated correctly). That's why, when you call a model's method, you should always provide that argument. The solution to achieve that is to use openerp.web.CompoundContext().

CompoundContext() is a class used to pass the user's context (with language, time zone, etc...) to the server as well as adding new keys to the context (some models' methods use arbitrary keys added to the context). It is created by giving to its constructor any number of dictionaries or other CompoundContext() instances. It will merge all those contexts before sending them to the server.

model.call("my_method", {context: new instance.web.CompoundContext({'new_key': 'key_value'})})
@api.model
def my_method(self):
    print self.env.context
    // will print: {'lang': 'en_US', 'new_key': 'key_value', 'tz': 'Europe/Brussels', 'uid': 1}

You can see the dictionary in the argument context contains some keys that are related to the configuration of the current user in Odoo plus the new_key key that was added when instantiating CompoundContext().

Запросы

While call() is sufficient for any interaction with Odoo models, Odoo Web provides a helper for simpler and clearer querying of models (fetching of records based on various conditions): query() which acts as a shortcut for the common combination of search() and :read(). It provides a clearer syntax to search and read models:

model.query(['name', 'login', 'user_email', 'signature'])
     .filter([['active', '=', true], ['company_id', '=', main_company]])
     .limit(15)
     .all().then(function (users) {
    // do work with users records
});

против:

model.call('search', [['active', '=', true], ['company_id', '=', main_company]], {limit: 15})
    .then(function (ids) {
        return model.call('read', [ids, ['name', 'login', 'user_email', 'signature']]);
    })
    .then(function (users) {
        // do work with users records
    });
  • query() takes an optional list of fields as parameter (if no field is provided, all fields of the model are fetched). It returns a openerp.web.Query() which can be further customized before being executed
  • Query() represents the query being built. It is immutable, methods to customize the query actually return a modified copy, so it's possible to use the original and the new version side-by-side. See Query() for its customization options.

When the query is set up as desired, simply call all() to execute it and return a deferred to its result. The result is the same as read()'s, an array of dictionaries where each dictionary is a requested record, with each requested field a dictionary key.

Упражнения

Существующие веб-компоненты

Менеджер действий

В Odoo многие операции начинаются с action: открытие элемента меню (для вида), печать отчета, ...

Действия - это фрагменты данных, описывающие реакцию клиента на активацию части содержимого. Действия могут быть сохранены (и считаны с помощью модели данных), или они могут быть сгенерированы «на лету» (локально для клиента кодом javascript или дистанционно с помощью метода модели данных).

В Odoo Web, компонентом, ответственным за обработку и реакцию на эти действия, является Action Manager.

Использование менеджера действий

Администратор действий может быть явно вызван из javascript-кода, создав словарь, описывающий действие нужного типа, и вызов экземпляра менеджера действий вместе с ним.

do_action() is a shortcut of Widget() looking up the "current" action manager and executing the action:

instance.web.TestWidget = instance.Widget.extend({
    dispatch_to_new_action: function() {
        this.do_action({
            type: 'ir.actions.act_window',
            res_model: "product.product",
            res_id: 1,
            views: [[false, 'form']],
            target: 'current',
            context: {},
        });
    },
});

Наиболее распространенный type действия - ir.actions.act_window , который предоставляет представления к модели (отображает модель различными способами), ее наиболее распространенными атрибутами являются:

res_model

Модель для отображения в представлениях

res_id (необязательный параметр)

Для представлений вида Form предварительно выбранная запись res_model

views

Для представлений вида Form предварительно выбранная запись [view_id, view_type], view_id может быть либо идентификатором представления правильного типа в базе данных , либо false чтобы использовать представление по умолчанию для указанного типа. Типы представлений не могут присутствовать несколько раз. Действие по умолчанию откроет первое представление из списка.

target

Либо current (по умолчанию), который заменяет «содержимое» раздела веб-клиента действием, либо new, чтобы открыть действие в диалоговом окне.

context

Дополнительные данные контекста для использования в действии.

Действия клиента

В этом руководстве мы использовали простой виджет HomePage, который автоматически запускается веб-клиентом, когда мы выбираем нужный пункт меню. Но как веб-клиент Odoo узнал о том, что нужно запустить именно этот виджет? Это потому, что виджет зарегистрирован как действие клиента.

Клиентское действие (как следует из его названия) является типом действия, определенным почти полностью в клиенте, в javascript для веб-клиента Odoo. Сервер просто отправляет тег действия (произвольное имя) и, при необходимости, добавляет несколько параметров, но помимо этого всё обрабатывается пользовательским кодом клиента.

Наш виджет зарегистрирован как обработчик для действия клиента следующим образом:

instance.web.client_actions.add('petstore.homepage', 'instance.oepetstore.HomePage');

instance.web.client_actions is a Registry() in which the action manager looks up client action handlers when it needs to execute one. The first parameter of add() is the name (tag) of the client action, and the second parameter is the path to the widget from the Odoo web client root.

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

На стороне сервера мы просто определили действие ir.actions.client:

<record id="action_home_page" model="ir.actions.client">
    <field name="tag">petstore.homepage</field>
</record>

добавляем действие в нужный пункт меню:

<menuitem id="home_page_petstore_menu" parent="petstore_menu"
          name="Home Page" action="action_home_page"/>

Архитектура представлений

Значительная часть полезных качеств (и соотвественно возникающих с этим сложностей) веб-клиента Odoo находится в представлениях. Каждый тип представления - это способ отображения модели данных на клиенте

Менеджер представлений

Когда экземпляр ActionManager получает действие типа ir.actions.act_window, , делегирует синхронизацию и обработку самих представлений менеджеру представлений, который затем настраивает одно или несколько представлений в зависимости от требований исходного действия:

Представления

Most Odoo views are implemented through a subclass of openerp.web.View() which provides a bit of generic basic structure for handling events and displaying model information.

Представление вида Search считается основным типом представления в Odoo фреймворке, но обрабатывается отдельно веб-клиентом (как правило оно является постоянным и может взаимодействовать с другими представлениями, чего не делают обычные представления).

A view is responsible for loading its own description XML (using fields_view_get) and any other data source it needs. To that purpose, views are provided with an optional view identifier set as the view_id attribute.

Views are also provided with a DataSet() instance which holds most necessary model information (the model name and possibly various record ids).

Views may also want to handle search queries by overriding do_search(), and updating their DataSet() as necessary.

Поля представлений вида Form

Обычной потребностью является расширение представления вида Form для добавления новых способов отображения полей.

All built-in fields have a default display implementation, a new form widget may be necessary to correctly interact with a new field type (e.g. a GIS field) or to provide new representations and ways to interact with existing field types (e.g. validate Char fields which should contain email addresses and display them as email links).

Чтобы явно указать, какой вид виджета следует использовать для отображения поля, просто используйте атрибут widget в XML описании представления:

<field name="contact_mail" widget="email"/>

Поля создаются экземпляром представления вида Form после того, как оно прочитало свое XML описание и сформирован соответствующий HTML код, представляющий это описание. После этого представление вида Form будет взаимодействовать с объектами поля с использованием методов. Эти методы определены интерфейсом FieldInterface. Почти все поля наследуют абстрактный класс AbstractField. Этот класс определяет некоторые механизмы по умолчанию, которые должны быть реализованы большинством полей.

Вот некоторые из обязанностей класса поля:

  • Класс поля должен отображать и разрешать пользователю редактировать значение поля.

  • Он должен правильно реализовать 3 атрибута поля, доступных во всех полях Odoo. Класс AbstractField уже реализует алгоритм, который динамически вычисляет значение этих атрибутов (они могут измениться в любой момент, потому что их значение изменяется в соответствии со значением других полей). Эти значения хранятся в Widget Properties (свойства виджета были объяснены ранее в этом руководстве). Каждый класс поля несет ответственность за проверку свойств виджета и динамическую адаптацию в зависимости от их значений. Вот описание каждого из этих атрибутов:

    • required: поле должно иметь значение перед сохранением. Если required имеет значение true и поле не имеет значения, метод поля is_valid() должен возвращать false.

    • invisible: Когда true, поле должно быть невидимым. Класс AbstractField уже имеет базовую реализацию этого поведения, которая подходит для большинства полей.

    • readonly: Когда true, поле нельзя редактировать пользователю. Большинство полей в Odoo имеют совершенно другое поведение в зависимости от значения readonly. Например, FieldChar отображает HTML <input> когда он редактируемый и просто отображает текст, когда он доступен только для чтения. Это также означает, что требуется гораздо больше кода для реализации только этого поведения, но это необходимо для создания удобного пользовательского интерфейса.

  • Поля имеют два метода, set_value() и get_value(), которые вызываются представлением вида Form, чтобы придать ему значение для отображения и возврата к новому значению, введенному пользователем. Эти методы должны уметь обрабатывать значение, заданное сервером Odoo, когда выполняется функция read() для модели данных и возвращать допустимое значение для write(). Помните, что типы данных JavaScript / Python, используемые для представления значений, заданных read() и отданных write() не обязательно должны быть одинаковы в Odoo. Например, когда вы читаете many2one, это всегда кортеж, первым значением которого является идентификатор указанной записи, а вторым является имя (например: (15, "Agrolait")). Но когда вы делаете запись в many2one, это должно быть одно целое число, а не кортеж. AbstractField имеет реализацию этих методов по умолчанию, которая хорошо работает для простых типов данных и устанавливает свойство виджета с именем value.

Обратите внимание, что для лучшего понимания того, как реализовать поля, вам настоятельно рекомендуется посмотреть определение интерфейса FieldInterface и класса AbstractField непосредственно в коде веб клиента Odoo.

Создание нового типа поля

В этой части мы объясним, как создать новый тип поля. Примером здесь будет повторная реализация класса `` FieldChar`` с постепенным объяснением каждой части.

Простое поле только для чтения

Вот первая реализация, которая будет отображать только текст. Пользователь не сможет изменять содержимое поля.

local.FieldChar2 = instance.web.form.AbstractField.extend({
    init: function() {
        this._super.apply(this, arguments);
        this.set("value", "");
    },
    render_value: function() {
        this.$el.text(this.get("value"));
    },
});

instance.web.form.widgets.add('char2', 'instance.oepetstore.FieldChar2');

В этом примере мы объявляем класс с именем FieldChar2, который является наследником``AbstractField``. Мы также регистрируем этот класс в реестре instance.web.form.widgets с ключом char2. Это позволит нам использовать это новое поле в любом представлении вида Form, указав widget="char2" в теге <field/> при создании XML описании представления.

В этом примере мы определяем единственный метод: render_value(). Все, что он делает - так это отображает value``свойства виджета. Это два инструмента, определенные классом ``AbstractField. Как объяснялось ранее, представление вида Form вызовет метод поля set_value(), чтобы установить отображаемое значение. У этого метода уже есть реализация по умолчанию в AbstractField, которая просто устанавливает value. AbstractField также смотрит событие change:value направленное на себя и вызывает render_value() когда это происходит. Таким образом, render_value() это удобный метод для реализации в дочерних классах выполнения некоторых операции каждый раз, когда изменяется значение поля.

В методе init() мы также определяем значение поля по умолчанию, если в представлении формы не указано ни одного (здесь мы предполагаем, что значение по умолчанию поля char должно быть пустой строкой).

Поле для чтения-записи

Поля, доступные только для чтения, которые отображают только контент и не позволяют пользователю изменять его, могут быть полезны, однако большинство полей в Odoo также позволяют себя редактировать. Это усложняет реализацию классов полей, главным образом потому, что поля должны обрабатывать как редактируемый, так и нередактируемый режим, эти режимы часто совершенно разные (для целей дизайна и удобства использования), и поля должны иметь возможность переключаться между режимами в любой момент.

Чтобы узнать, в каком режиме должно быть текущее поле, класс AbstractField задает свойство виджета с именем effective_readonly. Поле должно следить за изменениями в этом свойстве виджета и соответственно отображать правильный режим. Например:

local.FieldChar2 = instance.web.form.AbstractField.extend({
    init: function() {
        this._super.apply(this, arguments);
        this.set("value", "");
    },
    start: function() {
        this.on("change:effective_readonly", this, function() {
            this.display_field();
            this.render_value();
        });
        this.display_field();
        return this._super();
    },
    display_field: function() {
        var self = this;
        this.$el.html(QWeb.render("FieldChar2", {widget: this}));
        if (! this.get("effective_readonly")) {
            this.$("input").change(function() {
                self.internal_set_value(self.$("input").val());
            });
        }
    },
    render_value: function() {
        if (this.get("effective_readonly")) {
            this.$el.text(this.get("value"));
        } else {
            this.$("input").val(this.get("value"));
        }
    },
});

instance.web.form.widgets.add('char2', 'instance.oepetstore.FieldChar2');
<t t-name="FieldChar2">
    <div class="oe_field_char2">
        <t t-if="! widget.get('effective_readonly')">
            <input type="text"></input>
        </t>
    </div>
</t>

В методе start() (который вызывается сразу же после добавления виджета в DOM), мы связываемся с изменением события change:effective_readonly. Это позволяет нам повторно отображать поле каждый раз, когда свойство виджета изменяет значение effective_readonly. Этот обработчик будет вызывать display_field(), который также вызывается непосредственно в start(). Этот display_field() был создан специально для этого поля, это не метод, определенный в AbstractField или любом другом классе. Мы можем использовать этот метод для отображения содержимого поля в зависимости от текущего режима.

С этого момента концепция этого поля является типичной, за исключением того, что есть много проверок, чтобы узнать состояние свойства effective_readonly:

  • В шаблоне QWeb используемом для отображения содержимого виджета, он отображает <input type="text" /> если мы находимся в режиме чтения-записи, и ничего, если в режиме только для чтения.

  • В методе display_field() мы должны привязать событие change для <input type="text" /> чтобы знать, когда пользователь изменил значение. Когда это произойдет, мы вызываем метод internal_set_value() с новым значением поля. Это удобный метод, предоставляемый классом AbstractField. Этот метод установит новое значение в свойстве value но не вызовет вызов render_value() (это необязательно, поскольку <input type="text" /> уже содержит правильное значение).

  • В render_value() мы используем совершенно другой код для отображения значения поля в зависимости от того, находимся ли мы в режиме только для чтения или в режиме чтения-записи.

Пользовательские виджеты для представления вида Form

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

Пользовательские виджеты для представления вида Form могут быть добавлены через тег widget:

<widget type="xxx" />

Этот тип виджета будет просто создан представлением вида Form во время генерации HTML в соответствии с определением в XML описании. Они имеют общие свойства с полями (например, свойство effective_readonly) но им не присвоено точное поле. И поэтому они не имеют методов, таких как get_value() и set_value(). Они должны наследоваться от абстрактного класса FormWidget.

Form widgets can interact with form fields by listening for their changes and fetching or altering their values. They can access form fields through their field_manager attribute:

local.WidgetMultiplication = instance.web.form.FormWidget.extend({
    start: function() {
        this._super();
        this.field_manager.on("field_changed:integer_a", this, this.display_result);
        this.field_manager.on("field_changed:integer_b", this, this.display_result);
        this.display_result();
    },
    display_result: function() {
        var result = this.field_manager.get_field_value("integer_a") *
                     this.field_manager.get_field_value("integer_b");
        this.$el.text("a*b = " + result);
    }
});

instance.web.form.custom_widgets.add('multiplication', 'instance.oepetstore.WidgetMultiplication');

FormWidget is generally the FormView() itself, but features used from it should be limited to those defined by FieldManagerMixin(), the most useful being:

  • get_field_value(field_name)() which returns the value of a field.
  • set_values(values)() sets multiple field values, takes a mapping of {field_name: value_to_set}
  • Событие field_changed:field_name запускается каждый раз, когда изменяется значение поля с именем field_name.

[1]

как отдельное понятие из экземпляра. Во многих языках классы являются полноценными объектами а так же сами экземпляры (метаклассов), но это два достаточно разных поняти класс и экземпляр и между ними соблюдается четко прописанная иерархия

[2]

А также с учетом различий между браузерами, хотя со временем это стало менее необходимым