Module Development Tutorial

How to develop a custom module with Hexya.

1. Prerequisites

This tutorial requires having installed Hexya [install.adoc].

Important
We suppose that you have created a project called hexya-demo inside a directory of the same name, with github.com/hexya-erp/hexya-demo as path.

During this tutorial, you will create an openacademy module for managing training courses. We create this module inside the hexya-demo project directory as a local module:

$ cd hexya-demo
$ hexya module new openacademy

Now we also need to declare our new module inside the hexya.toml file. Change the Modules key to look like this:

Modules = [
    "github.com/hexya-erp/hexya-demo/openacademy",
    "github.com/hexya-addons/web"
]
Note

All file paths in this tutorial are relative to the hexya-demo directory where the hexya commands should be run.

2. Start/Stop the Hexya server

Hexya uses a client/server architecture in which clients are web browsers accessing the Hexya server via RPC.

Business logic and extension is generally performed on the server side, although supporting client features (e.g. new data representation such as interactive maps) can be added to the client.

In order to start the server, simply invoke the command hexya server in the shell. Add the -o flag to have the logs printed to the standard output.

The server is stopped by hitting Ctrl-C from the terminal, or by killing the corresponding OS process.

3. Build an Hexya module

Both server and client extensions are packaged as modules.

Hexya modules can either add brand new business logic to an Hexya system, or alter and extend existing business logic: a module can be created to add your country’s accounting rules to Hexya’s generic accounting support, while the next module adds support for real-time visualisation of a bus fleet.

Everything in Hexya thus starts and ends with modules.

3.1. Composition of a module

An Hexya module can contain a number of elements:

Business objects

Declared in Go code, these resources are automatically persisted by Hexya based on their configuration

View files

XML files declaring views, actions and menus

Data files

CSV files declaring configuration data (modules parameterization)

Web controllers

Handle requests from web browsers

Static web data

Images, CSS or javascript files used by the web interface or website

3.2. Module structure

Each module is a directory which is also a Go package. Optionally, a module may contain other Go packages as subdirectories of the main module.

An Hexya module is declared by code. By convention, this is in a file called 000hexya.go. The declaration consists in:

  • defining a MODULE_NAME string constant with the module’s name

  • registering the module with the RegisterModule() function.

Let’s create an 000hexya.go inside the hexya-demo/openacademy folder we created above:

openacademy/000hexya.go
package openacademy

import (
	"github.com/hexya-erp/hexya/src/server"
)

const MODULE_NAME string = "openacademy"

func init() {
    server.RegisterModule(&server.Module{
		Name: MODULE_NAME,
		PreInit: func() {},
		PostInit: func() {},
	})
}

The declared PreInit and PostInit functions allows to execute some code at server startup.

  • PreInit is run after all models are declared and configuration is loaded but before bootstrapping.

  • PostInit is run after the models, views and controllers are bootstrapped. We leave them as empty functions for the moment.

Note
We highly recommend that you use a Go IDE or editor with auto-completion to benefit from the static typing of the Hexya framework.

4. Object-Relational Mapping

A key component of Hexya is the ORM (Object-Relational Mapping) layer. This layer avoids having to write most SQL by hand and provides extensibility and security services.

4.1. Models

Business objects are declared in Go code and the framework integrates them into the automated persistence system. They must be declared either :

  • in an init() function of the package at the module root

  • in an init() function of a package in a subdirectory of the module, with this package imported in the root package.

  • in a function called by one of the above-mentioned init()

To put it short, the models definition must be read at the program startup during the module loading phase (see also https://golang.org/doc/effective_go.html#init)

Let’s create a new OpenAcademyCourse model in our openacademy module. We will do it in a new file named course.go, but it could have been done in any file of the module.

openacademy/course.go
package openacademy

import (
	"github.com/hexya-erp/hexya/src/models"
)

func init() {
    h.OpenAcademyCourse().DeclareModel()
}
Note

DeclareModel is called on our new not-yet-created model instance. This is because the above statement is a placeholder. The actual model declaration will be automatically generated by hexya generate command.

4.2. Code generation

In order to use our newly created model, we need to generate the code that will provide the structs and methods of this model.

This is done with the hexya generate command that must be executed at the project’s root (i.e. hexya-demo).

$ hexya generate .

After generation, our models can be accessed in the h, m and q packages:

  • github.com/hexya-erp/h is the package used to get model instances which are the entry point to accessing data.

  • github.com/hexya-erp/m mainly defines the types of record sets and record data objects.

  • github.com/hexya-erp/q is the query builder package of Hexya.

Important

hexya generate must be called before starting the server or synchronizing the database whenever there is a modification in a model definition, i.e.:

  • A model is created or removed

  • A field is added or removed

  • A method is added or removed

4.3. Model fields

Fields are used to define what the model can store and where. Fields are defined by the AddField method of the model.

Update the OpenAcademyCourse model to include a name and a description to our course.

openacademy/course.go
package openacademy

import (
	"github.com/hexya-erp/hexya/src/models"
	"github.com/hexya-erp/pool/h" (1)
)

func init() {
    h.OpenAcademyCourse().DeclareModel()
    h.OpenAcademyCourse().AddFields(map[string]models.FieldDefinition{
        "Name":        models.CharField{},
        "Description": models.TextField{},
    })
}
  1. Note that we need to import the generated h package to use it

4.3.1. Common Attributes

Fields can be configured, by passing configuration attributes in the params struct:

"Name":  models.CharField{Required: true},

Some attributes are available on all fields, here are the most common ones:

String

The label of the field in UI (visible by users). Defaults to the field’s name with spaces before capital letters (SaleOrderSale Order)

Required

If true, the field can not be empty, it must either have a default value or always be given a value when creating a record.

Help

Long-form, provides a help tooltip to users in the user interface.

Index

Requests that Hexya create a database index on the column.

4.3.2. Simple fields

There are two broad categories of fields: "simple" fields which are atomic values stored directly in the model’s table and "relational" fields linking records (of the same model or of different models).

Example of simple fields are Boolean, Date, Char.

4.3.3. Reserved fields

Hexya creates a few fields in all models. These fields are managed by the framework and shouldn’t be written to. They can be read if useful or necessary:

ID

The unique identifier for a record in its model.

CreateDate

Creation date of the record.

CreateUID

User who created the record.

WriteDate

Last modification date of the record.

WriteUID

User who last modified the record.

LastUpdate

Last time the record was updated (max(CreateDate, WriteDate)).

DisplayName

The name to display when this record is refered to.

HexyaExternalID

The external ID that is used when importing/exporting data

HexyaVersion

The version of the record data used when updating data

4.3.4. Special fields

By default, Hexya also requires a Name field on all models that will be used as default for DisplayName. This behaviour can be changed by overriding the NameGet() method of a model.

5. Resource files

Hexya is a highly data driven system. Although behavior is customized using Go code, some data can be defined in resource files instead for a better readability. They are:

  • Actions

  • Menus

  • Views

These resources are XML files that must be put in the resources subdirectory of the module. The framework automatically scans the resources directory, there is no need to declare the files.

Create a resources subdirectory in our openacademy module and a course.xml file inside with the following content:

openacademy/resources/course.xml
<?xml version="1.0" encoding="utf-8"?>
<hexya>
    <data>

    </data>
</hexya>

Action, menus and view definitions will come inside the data tag.

5.1. Actions

Actions define the behavior of the system in response to user actions: login, action button, selection of an invoice, …​ They can be triggered in three ways:

  • by clicking on menu items (linked to specific actions)

  • by clicking on buttons in views (if these are connected to actions)

  • as contextual actions on object

The main types of actions are:

Window action

Opens a specific view in the client

Server action

Execute a method on a model on the server

Report action

Generate and return a report

Create a window action to access our Course (inside the data tag):

openacademy/resources/course.xml
(...)
<action id="openacademy_course_action" name="Courses" model="OpenAcademyCourse"
        view_mode="tree,form" type="ir.actions.act_window">
    <help>
        <p class="oe_view_nocontent_create">Create the first course</p>
    </help>
</action>
(...)
Note
By convention, ids in XML files should start with the module name.

5.2. Menus

Menus trigger actions when they are clicked. Menus can have a parent to create a menu hierarchy.

Let’s create menus for our Courses action. Add the menuitem tags inside the data tags after the action block.

openacademy/resources/course.xml
(...)
<menuitem id="openacademy_main_menu" name="Open Academy"/>

<menuitem id="openacademy_menu" name="Open Academy" parent="openacademy_main_menu"/>

<menuitem id="openacademy_course_menu" name="Courses" parent="openacademy_menu"
          action="openacademy_course_action"/>
(...)
Note
XML files are all loaded before being processed. Therefore, there is no need to declare resources in a specific order. For instance, menus can refer to actions that are defined afterwards or in another file or module.

5.3. Views

See next section for view definitions.

6. Basic views

Views define the way the records of a model are displayed. Each type of view represents a mode of visualization (a list of records, a graph of their aggregation, …). Views can either be requested generically via their type (e.g. a list of partners) or specifically via their id. For generic requests, the view with the correct type and the lowest priority will be used (so the lowest-priority view of each type is the default view for that type).

View inheritance allows altering views declared elsewhere (adding or removing content).

6.1. Generic view declaration

A view is declared with the view tag. The basic view types are: list, form and search views.

6.1.1. List views

List views, also called tree views, display records in a tabular form. Their root element is <tree>.

Create a simple list view that only displays one column with the name of the course:

openacademy/resources/course.xml
(...)
<view id="openacademy_course_tree" model="OpenAcademyCourse">
    <tree>
        <field name="Name"/>
    </tree>
</view>
(...)

6.2. Form views

Forms are used to create and edit single records. Their root element is <form>. They are composed of high-level structure elements (groups, notebooks) and interactive elements (buttons and fields).

Create a form for our Course model:

openacademy/resources/course.xml
(...)
<view id="openacademy_course_form" model="OpenAcademyCourse">
    <form>
        <sheet>
            <group>
                <field name="Name"/>
            </group>
            <notebook>
                <page string="Description">
                    <field name="Description"/>
                </page>
                <page string="About">
                    This is an example of notebooks
                </page>
            </notebook>
        </sheet>
    </form>
</view>
(...)

Form views can also use plain HTML for more flexible layouts:

Example 1. Form view with plain HTML
    <form string="Idea Form">
        <header>
            <button string="Confirm" type="object" name="ActionConfirm"
                    states="draft" class="oe_highlight" />
            <button string="Mark as done" type="object" name="ActionDone"
                    states="confirmed" class="oe_highlight"/>
            <button string="Reset to draft" type="object" name="ActionDraft"
                    states="confirmed,done" />
            <field name="State" widget="statusbar"/>
        </header>
        <sheet>
            <div class="oe_title">
                <label for="Name" class="oe_edit_only" string="Idea Name" />
                <h1><field name="Name" /></h1>
            </div>
            <separator string="General" colspan="2" />
            <group colspan="2" col="2">
                <field name="Description" placeholder="Idea description..." />
            </group>
        </sheet>
    </form>

6.3. Search views

Search views customize the search field associated with the list view (and other aggregated views). Their root element is <search> and they’re composed of fields defining which fields can be searched on.

Let’s create a search view on our Course model to search on a course name or description.

openacademy/resources/course.xml
(...)
<view id="openacademy_course_search" model="OpenAcademyCourse">
    <search>
        <field name="Name"/>
        <field name="Description"/>
    </search>
</view>
(...)

If no search view exists for the model, Hexya generates one which only allows searching on the name field.

7. Synchronising the database and starting the server

At this stage, we have created:

  • An OpenAcademyCourse model with two fields Name and Description

  • A list view, a form view and a search view for our model

  • A menu and an action to access our model in the interface

To test our development, we can now synchronise the database with our model definitions using the hexya updatedb command in the project root directory.

$ hexya updatedb -o

The -o flag displays the log to the standard output.

When the database is synchronised, we can start the server to see our Course model in action.

$ hexya server -o

Now open your favorite web browser to http://localhost:8080 to access the application. Default credentials are:

User

admin

Password

admin

You should see on the top menu bar an Open Academy menu and a Courses menu on the left. Check that you can create, update, search and delete courses through the interface.

Important
  • Update the database each time you modify a model (add, remove or modify a field)

  • Restart the server each time you make a modification to see it applied

Tip
By adding a ?debug param in the address bar, you switch to developer mode in the UI, and will provide interesting information as tooltip when you hover a field’s label.

8. Relations between models

A record from a model may be related to a record from another model. For instance, a sale order record is related to a client record that contains the client data; it is also related to its sale order line records.

For the module Open Academy, we consider a model for sessions: a session is an occurrence of a course taught at a given time for a given audience.

Let’s create a model for sessions. A session has a name, a start date, a duration and a number of seats. Although we could have used the same file we will create a new file for sessions to organise our code.

Note
Don’t forget to run hexya generate and hexya updatedb after creating the new model
openacademy/session.go
package openacademy

import (
	"github.com/hexya-erp/hexya/src/models"
	"github.com/hexya-erp/hexya/src/models/types"
	"github.com/hexya-erp/pool/h"
)

func init() { (1)
    h.OpenAcademySession().DeclareModel()
    h.OpenAcademySession().AddFields(map[string]models.FieldDefinition{
        "Name":      models.CharField{Required: true},
        "StartDate": models.DateField{},
        "Duration":  models.FloatField{Digits: nbutils.Digits{Precision: 6, Scale: 2},
            Help: "Duration in days"},
        "Seats":     models.IntegerField{String: "Number of seats"},
        "State":     models.SelectionField{
            Selection: types.Selection{"planned": "Planned", "in_progress":"In Progress", "done": "Finished"}}, (2)
    })
}
  1. You can define as many init() functions in a Go package

  2. A Selection field takes a value from a fixed list

Note
nbutils.Digits{Precision: 6, Scale: 2} specifies the precision of a float number: 6 is the total number of digits, while 2 is the number of digits after the decimal point. Note that it results in the number digits before the decimal point is maximum 4.

Now we create an action and menu to display our sessions. Here again, we will create a new file to organise our code, though this is not mandatory.

openacademy/resources/session.xml
<hexya>
    <data>

        <view id="openacademy_session_tree" model="OpenAcademySession">
            <tree>
                <field name="Name"/>
                <field name="StartDate"/>
            </tree>
        </view>

        <view id="openacademy_session_form" model="OpenAcademySession">
            <form string="Session Form">
                <header>
                    <field name="State" widget="statusbar"/>
                </header>
                <sheet>
                    <group>
                        <field name="Name"/>
                        <field name="StartDate"/>
                        <field name="Duration"/>
                        <field name="Seats"/>
                    </group>
                </sheet>
            </form>
        </view>

        <action id="openacademy_sessions_action" name="Sessions" model="OpenAcademySession"
                type="ir.actions.act_window" view_mode="tree,form"/>

        <menuitem id="openacademy_session_menu" name="Sessions"
                  parent="openacademy_menu" action="openacademy_sessions_action"/>

    </data>
</hexya>

8.1. Relational fields

Relational fields link records, either of the same model (hierarchies) or between different models.

Relational field types are:

Many2One

A simple link to another object.

h.SaleOrder().AddFields(map[string]models.FieldDefinition{
    "Customer": models.Many2OneField{RelationModel: h.Partner()},
})

fmt.Println(myOrder().Customer().Name())
One2Many

A virtual relationship, inverse of a Many2one. A One2many behaves as a container of records, accessing its results in a (possibly empty) set of records.

h.SaleOrder().AddFields(map[string]models.FieldDefinition{
    "OrderLines": models.One2ManyField{RelationModel: h.SaleOrderLine(), ReverseFK: "Order"},
})

for _, orderLine := range myOrder().OrderLines().Records() {
    fmt.Println(orderLine.AmountTotal())
}
Important
Because a One2many is a virtual relationship, there must be a Many2one field in the other model and its name must be the name defined by ReverseFK.
Many2Many

Bidirectional multiple relationship, any record on one side can be related to any number of records on the other side. Behaves as a container of records, accessing it also results in a possibly empty set of records.

h.SaleOrder().AddFields(map[string]models.FieldDefinition{
    "Tags": models.Many2ManyField{RelationModel: h.Tag()},
})

for _, tag := range myOrder().Tags().Records() {
    fmt.Println(tag.Name())
}

Let’s add relations to our Course and Session models.

openacademy/course.go
package openacademy

import (
	"github.com/hexya-erp/hexya/src/models"
	"github.com/hexya-erp/pool/h"
)

func init() {
    h.OpenAcademyCourse().DeclareModel()
    h.OpenAcademyCourse().AddFields(map[string]models.FieldDefinition{
        "Name":        models.CharField{},
        "Description": models.TextField{},
        "Responsible": models.Many2OneField{
            RelationModel: h.User(), OnDelete: models.SetNull, Index: true},
        "Sessions":    models.One2ManyField{
            RelationModel: h.OpenAcademySession(), ReverseFK: "Course"},
    })
}
openacademy/session.go
package openacademy

import (
	"github.com/hexya-erp/hexya/src/models"
	"github.com/hexya-erp/hexya/src/models/types"
	"github.com/hexya-erp/pool/h"
)

func init() {
    h.OpenAcademySession().DeclareModel()
    h.OpenAcademySession().AddFields(map[string]models.FieldDefinition{
        "Name":       models.CharField{Required: true},
        "StartDate":  models.DateField{},
        "Duration":   models.FloatField{Digits: nbutils.Digits{Precision: 6, Scale: 2},
            Help: "Duration in days"},
        "Seats":      models.IntegerField{String: "Number of seats"},
        "State":      models.SelectionField{
            Selection: types.Selection{"planned": "Planned", "in_progress":"In Progress", "done": "Finished"}}, (2)
        "Instructor": models.Many2OneField{RelationModel: h.Partner()},
        "Course":     models.Many2OneField{RelationModel: h.OpenAcademyCourse(),
            Required: true, OnDelete: models.Cascade},
        "Attendees":  models.Many2ManyField{RelationModel: h.Partner()},
    })
}

Now we update their views accordingly.

openacademy/resources/course.xml
<?xml version="1.0" encoding="utf-8"?>
<hexya>
    <data>

        <view id="openacademy_course_search" model="OpenAcademyCourse">
            <search>
                <field name="Name"/>
                <field name="Description"/>
            </search>
        </view>

        <view id="openacademy_course_tree" model="OpenAcademyCourse">
            <tree>
                <field name="Name"/>
                <field name="Responsible"/>
            </tree>
        </view>

        <view id="openacademy_course_form" model="OpenAcademyCourse">
            <form>
                <sheet>
                    <group>
                        <field name="Name"/>
                        <field name="Responsible"/>
                    </group>
                    <notebook>
                        <page string="Description">
                            <field name="Description"/>
                        </page>
                        <page string="Sessions">
                            <field name="Sessions">
                                <tree string="Registered sessions">
                                    <field name="Name"/>
                                    <field name="Instructor"/>
                                </tree>
                            </field>
                        </page>
                        <page string="About">
                            This is an example of notebooks
                        </page>
                    </notebook>
                </sheet>
            </form>
        </view>

        <action id="openacademy_course_action" name="Courses" model="OpenAcademyCourse"
                view_mode="tree,form" type="ir.actions.act_window">
            <help>
                <p class="oe_view_nocontent_create">Create the first course</p>
            </help>
        </action>

        <menuitem id="openacademy_main_menu" name="Open Academy"/>

        <menuitem id="openacademy_menu" name="Open Academy" parent="openacademy_main_menu"/>

        <menuitem id="openacademy_course_menu" name="Courses" parent="openacademy_menu"
                  action="openacademy_course_action"/>

    </data>
</hexya>
openacademy/resources/session.xml
<hexya>
    <data>

        <view id="openacademy_session_tree" model="OpenAcademySession">
            <tree>
                <field name="Name"/>
                <field name="Course"/>
                <field name="StartDate"/>
            </tree>
        </view>

        <view id="openacademy_session_form" model="OpenAcademySession">
            <form string="Session Form">
                <header>
                    <field name="State" widget="statusbar"/>
                </header>
                <sheet>
                    <group>
                        <group string="General">
                            <field name="Course"/>
                            <field name="Name"/>
                            <field name="Instructor"/>
                        </group>
                        <group string="Schedule">
                            <field name="StartDate"/>
                            <field name="Duration"/>
                            <field name="Seats"/>
                        </group>
                    </group>
                    <label for="Attendees"/>
                    <field name="Attendees"/>
                </sheet>
            </form>
        </view>

        <action id="openacademy_sessions_action" name="Sessions" model="OpenAcademySession"
                type="ir.actions.act_window" view_mode="tree,form"/>

        <menuitem id="openacademy_session_menu" name="Sessions"
                  parent="openacademy_menu" action="openacademy_sessions_action"/>

    </data>
</hexya>

9. Inheritance

9.1. Model inheritance

Hexya provides two inheritance mechanisms to extend an existing model in a modular way: extension and embedding.

Extension allows a module to modify the behavior of a model defined in another module:

  • add fields to a model,

  • override the definition of fields on a model,

  • add constraints to a model,

  • add methods to a model,

  • override existing methods on a model.

Embedding allows to link every record of a model to a record in a parent model, and provides transparent access to the fields of the parent record.

9.2. View inheritance

Instead of modifying existing views in place (by overwriting them), Hexya provides view inheritance where children "extension" views are applied on top of root views, and can add or remove content from their parent.

An extension view references its parent using the inherit_id attribute instead of the id attribute. Instead of a single view its content field is composed of any number of xpath elements selecting and altering the content of their parent view.

Example 2. View inheritance
    <view inherit_id="id_category_list" model="IdeaCategory">
        <!-- find field Description and add the field Ideas after it -->
        <xpath expr="//field[@name='Description']" position="after">
          <field name="Ideas" string="Number of ideas"/>
        </xpath>
    </view>
expr

An XPath expression selecting a single element in the parent view. Raises an error if it matches no element or more than one

position

Operation to apply to the matched element. Possible operations are:

inside

Appends `xpath’s body at the end of the matched element

replace

Replaces the matched element with the xpath’s body, replacing any `$0 node occurrence in the new body with the original element

before

Inserts the `xpath’s body as a sibling before the matched element

after

Inserts the `xpaths’s body as a sibling after the matched element

attributes

Alters the attributes of the matched element using special attribute elements in the `xpath’s body

Tip

When matching a single element, the position attribute can be set directly on the element to be found. Both inheritances below will give the same result.

        <xpath expr="//field[@name='Description']" position="after">
            <field name="Ideas" />
        </xpath>

        <field name="Description" position="after">
            <field name="Ideas" />
        </field>

9.3. Example

Let’s modify the existing Partner model (defined in Hexya’s base module) to add an instructor boolean field, and a Many2Many field that corresponds to the session-partner relation.

openacademy/partner.go
package openacademy

import (
	"github.com/hexya-erp/hexya/src/models"
	"github.com/hexya-erp/pool/h"
)

func init() {
    h.Partner().AddFields(map[string]models.FieldDefinition{
        "Instructor":       models.BooleanField{},
        "AttendedSessions": models.Many2ManyField{RelationModel: h.OpenAcademySession()}
    })
}
openacademy/resources/partner.xml
<?xml version="1.0" encoding="UTF-8"?>
<hexya>
    <data>

        <!-- Add instructor field to existing view -->
        <view inherit_id="base_view_partner_form" model="Partner">
            <notebook position="inside">
                <page string="Sessions">
                    <group>
                        <field name="Instructor"/>
                        <field name="Sessions"/>
                    </group>
                </page>
            </notebook>
        </view>

        <action id="openacademy_partners_action" model="Partner"
                view_mode="tree,form" type="ir.actions.act_window"/>

        <menuitem id="openacademy_configuration_menu" name="Configuration"
                  parent="openacademy_main_menu"/>
        <menuitem id="openacademy_contact_menu" name="Contacts"
                  parent="openacademy_configuration_menu"
                  action="openacademy_partners_action"/>

    </data>
</hexya>

Hexya allows to limit the list of available record candidates for a relation. For example, in our Open Academy, for a session, we want to be able to select an instructor only among partners that have the boolean field Instructor set to true.

Not yet implemented

10. Model methods

With Hexya, you can define methods on models to implement business logic.

Methods are created by DeclareMethod():

Example 3. Creating a method
import (
(...)
	// We need to import m to define methods
	"github.com/hexya-erp/pool/m"
)
(...)
h.OpenAcademyCourse().Methods().StartSession().DeclareMethod(
    `StartSession sets the State of the Session to 'Started'`,
    func(rs m.OpenAcademyCourseSet) {
        // Update State for all record in rs
        rs.SetState("in_progress")
    })

// calling the method somewhere else
myCourseSet.StartSession()
Note

DeclareMethod is called on our new not-yet-created method instance. This is because the above statement is a placeholder. The actual method creation will be automatically generated by hexya generate command.

Note
m.OpenAcademyCourseSet is the record set type for the OpenAcademyCourse model. It holds a set of OpenAcademyCourse records. All model methods are called on (possibly empty) record sets.

Methods can be overridden in other modules with Extend() on the method object. Call rs.Super().MyMethod() to execute the original implementation.

Example 4. Overridding/Extending a method
h.OpenAcademyCourse().Methods().StartSession().Extend("",
    func(rs m.OpenAcademyCourseSet) {
        rs.Super().StartSession()
        fmt.Println("Session started")
    })

11. Computed fields and default values

So far fields have been stored directly in and retrieved directly from the database. Fields can also be computed. In that case, the field’s value is not retrieved from the database but computed on-the-fly by calling a method of the model.

To create a computed field, create a field and set its attribute Compute to a method. The computation method should have the following signature:

func (RecordSet) RecordData

RecordSet type depends on the model and is named m.ModelSet for model Model (e.g. m.OpenAcademySessionSet)

Note
In the case of a computation method, the given record set is a singleton.

RecordData type is a struct with all the fields of the model to hold a record. It is named m.ModelData for model Model (e.g. m.OpenAcademySessionData)

11.1. Dependencies

The value of a computed field usually depends on the values of other fields on the computed record. The ORM expects the developer to specify those dependencies on the compute method by specifying the Depends attribute of the field. The given dependencies are used by the ORM to trigger the recomputation of the field whenever some of its dependencies have been modified.

Let’s add the percentage of taken seats to the Session model.

openacademy/session.go
func init() {
(...)
    "TakenSeats": models.FloatField{
        Compute: h.OpenAcademySession().Methods().ComputeTakenSeats(),
        Depends: []string{"Seats", "Attendees"}},

(...)

    h.OpenAcademySession().Methods().ComputeTakenSeats().DeclareMethod(
        `ComputeTakenSeats returns the percentage of taken seats in this session`,
        func(rs m.OpenAcademySessionSet) m.OpenAcademySessionData {
            res h.OpenAcademySession().NewData()
            if rs.Seats() != 0 {
                res.SetTakenSeats(100.0 * float64(rs.Attendees().Len()) / float64(rs.Seats()))
            }
            return res
        })
(...)
}

Now let’s add our new field in the form and tree views:

openacademy/resources/session.xml
(...tree view...)
            <tree string="Session Tree">
                <field name="Name"/>
                <field name="Course"/>
                <field name="TakenSeats" widget="progressbar"/>
            </tree>
        </view>
(...)

(...form view...)
                <field name="StartDate"/>
                <field name="Duration"/>
                <field name="Seats"/>
                <field name="TakenSeats" widget="progressbar"/>
            </group>
        </group>
        <label for="Attendees"/>
(...)

11.2. Default values

Any field can be given a default value. In the field definition, add the option Default: X where X is a function with the following signature:

func (models.Environment) interface{}

The default function is called when providing an empty form in the user interface during record creation.

Note

The Environment object gives access to request parameters and other useful things. The current Environment can be retrieved from a record set with the Env() method.

  • rs.Env().Cr() is the database cursor object; it is used for querying the database directly.

  • rs.Env().Uid() is the current user’s database id

  • rs.Env().Context() is the context dictionary

Tip
For a constant default value, the models package provides a DefaultValue function that takes the constant as single argument and returns a suitable function for Default:

Now we will set StartDate default value to today, and create a new Active field that defaults to true.

openacademy/session.go
func init() {
(...)
    "StartDate": models.DateField{
        Default: func(env models.Environment) interface{} {
                return dates.Today()
            },
        },
(...)
    "Active": models.BooleanField{Default: models.DefaultValue(true)},
(...)
}

12. Onchange

The "onchange" mechanism provides a way for the client interface to update a form whenever the user has filled in a value in a field, without saving anything to the database.

To achieve this, set the OnChange: parameter of the field with a computation function.

Such fonction have the following signature:

func (RecordSet) RecordData

For computed fields, valued onchange behavior is built-in as can be seen by playing with the Session form: change the number of seats or participants, and the TakenSeats progressbar is automatically updated.

We now add an explicit onchange to prevent negative number of seats.

openacademy/session.go
func init() {
(...)
    "Seats": models.IntegerField{
        String: "Number of seats",
        OnChange: h.OpenAcademySession().Methods().VerifyValidSeats()},
(...)
   "Attendees": models.Many2ManyField{
        RelationModel: h.Partner(),
        OnChange: h.OpenAcademySession().Methods().VerifyValidSeats()},
(...)

    h.OpenAcademySession().Methods().VerifyValidSeats().DeclareMethod(
        `VerifyValidSeats checks that the number of seats is positive
        and resets it to zero otherwise`,
        func (rs m.OpenAcademySessionSet) m.OpenAcademySessionData {
            res := h.OpenAcademySession().NewData()
            if rs.Seats() < 0 {
                res.SetSeats(0)
            }
            return res
        })
(...)
}

It is also possible to send a warning or a new domain to the UI when a field is changed:

Not yet implemented

13. Model constraints

13.1. Filtering Relation Record Candidates

A Filter parameter can be added to relational fields to limit valid records for the relation when trying to select records in the client interface. This parameter takes a condition on the relation model. Conditions are built with the q package

We can add a domain to limit instructors to partners who are instructors:

openacademy/session.go
import (
(...)
	"github.com/hexya-erp/pool/q"
)
(...)
    "Instructor": models.Many2OneField{RelationModel: h.Partner(),
        Filter: q.Partner().Instructor().Equals(true)},
(...)

13.2. SQL Constraints

You can add SQL constraints to a model with the AddSQLConstraint() method of a Model. This method takes a name for the constraint, the SQL code to enforce and a string to display to the user when the constraint is violated.

Unique constraints on a single field can also be specified by setting the Unique: parameter of the field’s declaration to true.

Let’s add the following SQL constraints to our sessions:

  • CHECK that the course description and the course title are different

  • Make the Course’s name UNIQUE

openacademy/course.go
(...)
    "Name": models.CharField{Unique: true},
(...)
    h.OpenAcademyCourse().AddSQLConstraint("name_description_check",
         "CHECK(name != description)",
         "The title of the course should not be the description")
(...)

13.3. Constraint by method

Constraints on models can also be defined as methods on models. This is done by setting the Constraint: parameter of a field to a method that will check if its given recordset is valid. This method must panic if it is not the case.

Note
Several fields can set their Constraint: to the same method. In this case the method will only be called once, even if both fields are modified.

Now we will add a constraint that checks that the instructor is not present in the attendees of his/her own session.

openacademy/session.go
(...)
    "Instructor": models.Many2OneField{RelationModel: h.Partner(),
        Filter: q.Partner().Instructor().Equals(true),
        Constraint: h.OpenAcademySession().Methods().CheckInstructorNotInAttendees()},
    "Attendees": models.Many2ManyField{RelationModel: h.Partner(),
        OnChange: h.OpenAcademySession().Methods().VerifyValidSeats(),
        Constraint: h.OpenAcademySession().Methods().CheckInstructorNotInAttendees()},

(...)

    h.OpenAcademySession().Methods().CheckInstructorNotInAttendees().DeclareMethod(
        `CheckInstructorNotInAttendees checks that the instructor is not present
        in the attendees of his/her own session`,
        func(rs m.OpenAcademySessionSet) {
            for _, attendee := range rs.Attendees().Records() {
                if attendee.ID() == rs.Instructor().ID() {
                    panic("The session's instructor can't be an attendee of his own session")
                }
            }
        })
(...)

14. Advanced Views

14.1. List views

List views can take supplementary attributes to further customize their behavior:

decoration-{$name}

Allows changing the style of a row’s text based on the corresponding record’s attributes.

Values are Python expressions. For each record, the expression is evaluated with the record’s attributes (by their JSON names) as context values and if true, the corresponding style is applied to the row. Other context values are uid (the id of the current user) and current_date (the current date as a string of the form yyyy-MM-dd).

Note

Each field definition has a name and a so-called JSON name which is the name of the column in the database and the field name sent to the client.

If not specificied in the field definition, the JSON name defaults to the snake case value of the field’s name, with the following subtilties:

  • It is Go camel case aware: DescriptionHTMLdescription_html

  • Many2One fields are appended _id: Partnerpartner_id

  • One2Many and Many2many are appended _ids: OrderLinesorder_lines_ids

Important
All field values that are evaluated must appear in the tree view, possibly with an invisible="1" attribute, if you don’t want it displayed.

{$name} can be bf (font-weight: bold), it (font-style: italic), or any bootstrap contextual color (http://getbootstrap.com/components/#available-variations: danger, info, muted, primary, success or warning).

Example 5. List view with specified row styles
    <tree string="Idea Categories" decoration-info="state=='draft'"
        decoration-danger="state=='trashed'">
        <field name="Name"/>
        <field name="State"/>
    </tree>
editable

Either "top" or "bottom". Makes the tree view editable in-place (rather than having to go through the form view), the value is the position where new rows appear.

Let’s color our Session tree view in such a way that sessions lasting less than 5 days are colored blue, and the ones lasting more than 15 days are colored red:

(...)
        <view  id="openacademy_session_tree" model="OpenAcademySession">
            <tree string="Session Tree" decoration-info="duration&lt;5" decoration-danger="duration&gt;15">
                <field name="Name"/>
                <field name="Course"/>
                <field name="Duration" invisible="1"/>
                <field name="TakenSeats" widget="progressbar"/>
            </tree>
        </view>
(...)

14.2. Calendars

Not yet implemented

14.3. Search views

Search view <field> elements can have a filter_domain attribute that overrides the condition generated for searching on the given field. In the given domain, self represents the value entered by the user.

Note

As Hexya uses Odoo web client, conditions on client side are defined by so-called "domains". See the following links for more documentation:

Search views can also contain <filter> elements, which act as toggles for predefined searches. Filters must have one of the following attributes:

domain

Search condition to apply when the filter is activated

context

Add some context to the current search; use the key group_by to group results on the given field name

To use a non-default search view in an action, it should be linked using the search_view_id attribute of the action record.

The action can also set default values for search fields through its context field: context keys of the form search_default_{field_name} will initialize field_name with the provided value. Search filters must have an optional name to have a default and behave as booleans (they can only be enabled by default).

Now we will

  • Add a button to filter the courses for which the current user is the responsible in the course search view. Make it selected by default.

  • Add a button to group courses by responsible user.

openacademy/resources/course.xml
(...)
            <search>
                <field name="Name"/>
                <field name="Description"/>
                <filter name="my_courses" string="My Courses"
                        domain="[('responsible_id', '=', uid)]"/>
                <group string="Group By">
                    <filter name="by_responsible" string="Responsible"
                            context='{"group_by": "responsible_id"}'/>
                </group>
            </search>
(...)
        <action id="openacademy_sessions_action" name="Sessions" model="OpenAcademySession"
                type="ir.actions.act_window" view_mode="tree,form"
                context='{"search_default_my_courses": 1}'/>
(...)

14.4. Gantt

Not yet implemented

14.5. Graph views

Not yet implemented

14.6. Kanban

Not yet implemented

15. Security

Access control mechanisms must be configured to achieve a coherent security policy.

15.1. Group-based access control mechanisms

Groups are created by code and registered in the framework like this:

myGroup := security.NewGroup("my_group", "My Group")
security.Registry.RegisterGroup(newGroup)

They can be retrieved by id this way:

myGroup := security.Registry.GetGroup("my_group")

But it is good practice to define each group as package level variables in the module that registers it, so that other modules can import and use the variable directly, such as base.GroupUser.

Groups are granted menu access via menu definitions. However even without a menu, objects may still be accessible indirectly, so actual object-level permissions must be defined for groups.

Unlike other systems, there is no CRUD permissions on objects. Instead, execution permission is granted to groups on a per-method basis. Classical CRUD access control is possible by granting execution on Create, Load, Write and Unlink methods of a model.

It is also possible to restrict access to specific fields on a view or through the InvisibleFunc and ReadOnlyFunc field attributes.

15.2. Method Execution Control

By default:

  • CRUD methods can only be executed by members of security.AdminGroup. Other groups should be manually added to allowed groups.

  • Other methods can be executed by anybody. To restrict execution, you should first revoke execution permission from security.GroupEveryone before granting permission to the desired groups.

This is done with AllowGroup() and RevokeGroup() applied on a method object.

Note
With the helper method AllowAllToGroup() applied on the method collection of an object, we can grant access to all CRUD methods of a model at once to a group.

Now we create :

  • a group "OpenAcademy / Session Read" with read access to the Session model.

  • a group "OpenAcademy / Manager" with full access to our models

openacademy/hexya.go
package openacademy

import (
	"github.com/hexya-erp/hexya/src/models/security"
	"github.com/hexya-erp/hexya/src/server"
)

const MODULE_NAME string = "openacademy"

var (
    SessionRead *security.Group
    Manager     *security.Group
)

func init() {
    server.RegisterModule(&server.Module{
		Name: MODULE_NAME,
		PreInit: func() {},
		PostInit: func() {},
	})
}
openacademy/course.go
func init() {
    (...)
    SessionRead = security.Registry.NewGroup("openacademy_session_read", "OpenAcademy / Session Read")
    Manager = security.Registry.NewGroup("openacademy_manager", "OpenAcademy / Manager", SessionRead)

    h.OpenAcademyCourse().Methods().Load().AllowGroup(SessionRead)
    h.OpenAcademyCourse().Methods().AllowAllToGroup(Manager)
    (...)
}
openacademy/session.go
func init() {
(...)
    h.OpenAcademySession().Methods().AllowAllToGroup(Manager)
(...)
}
Note
Since our Manager group inherits from SessionRead we don’t need to give permission on the Load method again.

To test our access control,

  • Click on the Reload Groups menu in the Settings

  • Create a new user "John Smith", and make him a member of SessionRead

  • Log in as John Smith to check the access rights are correct

15.3. Field Access Control

There is no Field Access Control as such in Hexya, but it is possible to hide fields or set them as readonly on views, based on the context.

This can be done in two ways:

  • Through the invisible, readonly and attrs attributes of view definitions.

  • Through the InvisibleFunc and ReadOnlyFunc field attributes in the field declaration. In this case, the result applies to all views.

15.3.1. View attributes

invisible

Setting the attribute invisible="1" to a field node in a view definition will hide the field from the user. This is mostly used for technical fields that are needed for client side computation but must not be displayed.

readonly

Setting the attribute readonly="1" to a field node, will set it as read only for this view. Note that computed fields without inverse method are automatically set as read only.

groups

This attribute can be set to a comma-separated list of security groups IDs. If set, the field will be visible only to users of the given groups.

attrs

This attribute is used to have dynamic values of the readonly and invisible attributes.

The value of this attribute must be a Python dictionary whose keys are invisible, readonly and required. The value for each key must be an Odoo domain evaluated on client side that defines when the corresponding attribute should be true.

(...)
    <field name="TakenSeats" attrs="{'invisible': [('Seats', '=', False)]}"/>
(...)

15.3.2. Field attributes

Fields have Required and ReadOnly attributes which, if true, will be set for every view and will override attributes in the views.

Dynamic behaviour can be obtained through the RequiredFunc, ReadOnlyFunc and InvisibleFunc attributes. Each of this attribute take as value a function with the following signature:

func (Environment) (bool, Conditioner)

If the second argument is not nil, it must be a condition that will override the attrs attribute of the view. If it is nil, the first argument is used to override the corresponding attribute in the view.

Note
These functions are evaluated when the client requests the view definition, which may be as little as once a session. The returned condition if any is evaluated at each request by the client.

15.4. Record rules

Record Rules allow to grant or deny a group some permissions on a selection of records. This could be the case for example to allow a salesman only to see his own sales.

A Record Rule is a struct with the following definition, in the models package:

type RecordRule struct {
    Name      string
    Global    bool
    Group     *Group
    Condition *models.Condition
    Perms     Permission
}

Let’s add a record rule for the model Course and the group "OpenAcademy / Manager", that restricts write and unlink accesses to the responsible of a course. If a course has no responsible, all users of the group must be able to modify it.

openacademy/course.go
import (
(...)
	"github.com/hexya-erp/pool/q"
)

func getCurrentUser(rs h.OpenAcademyCourseSet) h.UserSet {
    return h.User().NewSet(rs.Env()).CurrentUser()
}

func init() {
(...)
    cond := q.OpenAcademyCourse().
        Responsible().EqualsFunc(getCurrentUser).
        Or().Responsible().IsNull()

    rule := models.RecordRule {
        Name:      "openacademy_manager_course_write_unlink",
        Group:     Manager,
        Condition: cond.Condition,
        Perms:     security.Write|security.Unlink,
    }
    h.OpenAcademyCourse().AddRecordRule(&rule)
(...)
}

16. Wizards

Wizards describe interactive sessions with the user (or dialog boxes) through dynamic forms. A wizard is simply a model that is created with DeclareTransientModel instead of DeclareModel. The resulting model has the following particularities:

  • Wizard records are not meant to be persistent; they are automatically deleted from the database after a certain time. This is why they are called transient.

  • Wizard models do not require explicit access rights: users have all permissions on wizard records.

  • Wizard records may refer to regular records or wizard records through many2one fields, but regular records cannot refer to wizard records through a many2one field.

We want to create a wizard that allow users to create attendees for a particular session, or for a list of sessions at once.

openacademy/wizard.go
package openacademy

import (
	"github.com/hexya-erp/hexya/src/models"
	"github.com/hexya-erp/pool/h"
	"github.com/hexya-erp/pool/q"
)

func init() {
    h.OpenAcademyWizard().DeclareTransientModel()
    h.OpenAcademyWizard().AddFields(map[string]models.FieldDefinition{
        "Session": models.Many2OneField{RelationModel: h.OpenAcademySession(),
            Required: true},
        "Attendees": models.Many2ManyField{RelationModel: h.Partner()},
    })
}

16.1. Launching wizards

Wizards are launched by window actions, with the attribute target set to the value new. The latter opens the wizard view into a popup window. The action may be triggered by a menu item.

There is another way to launch the wizard: using an ir.actions.act_window record like above, but with an extra field src_model that specifies in the context of which model the action is available. The wizard will appear in the contextual actions of the model, above the main view.

Wizards use regular views and their buttons may use the attribute special="cancel" to close the wizard window without saving.

Let’s:

  • Define a form view for the wizard

  • Add the action to launch it in the context of the Session model

  • Define a default value for the session field in the wizard

  • Create a method to subscribe users to the session

openacademy/wizard.go
func init() {
(...)
    "Session": models.Many2OneField{RelationModel: h.OpenAcademySession(),
        Required: true, Default:
            func(env models.Environment) interface{} {
                activeID := env.Context().GetInteger("active_id")
                return h.OpenAcademySession().Search(env,
                    q.OpenAcademySession().ID().Equals(activeID))
            }
        },

(...)

    h.OpenAcademyWizard().Methods().Subscribe().DeclareMethod(
        `Subscribe subscribes the users to the session`,
        func(rs m.OpenAcademyWizardSet) {
            rs.Session().SetAttendees(rs.Session().Attendees().Union(rs.Attendees()))
        })
(...)
}
openacademy/resources/wizard.xml
<hexya>
    <data>
        <view id="openacademy_wizard_form" model="OpenAcademyWizard">
            <form string="Add Attendees">
                <group>
                    <field name="Session"/>
                    <field name="Attendees"/>
                </group>
                <footer>
                    <button name="Subscribe" type="object"
                            string="Subscribe" class="oe_highlight"/>
                    or
                    <button special="cancel" string="Cancel"/>
                </footer>
            </form>
        </view>

        <action id="openacademy_launch_session_wizard"
                type="ir.actions.act_window"
                name="Add Attendees"
                src_model="OpenAcademySession"
                model="OpenAcademyWizard"
                view_mode="form"
                target="new"/>
    </data>
</hexya>

17. Internationalization

17.1. Translating the application

The application terms can be translated by use of PO files that live in the `i18n/`subdirectory of our module. Let’s see how to create a French translation:

$ mkdir openacademy/i18n
$ hexya i18n update ./openacademy -l fr

Now you should have a PO file inside openacademy/i18n directory with the module strings to translate. Go for the translation, and save the file.

Restart the application, but pass the -l fr parameter to load the french translation:

hexya server -o -l fr

After logging in, go to the preferences menu (top right of the screen) and set the lang field to fr. Reload your browser’s page and you should see your translated terms instead of the original ones.

17.2. Translating record data

Not implemented yet

18. Reporting

18.1. Printed reports

Not implemented yet

18.2. Dashboards

Not implemented yet