- 1. Prerequisites
- 2. Start/Stop the Hexya server
- 3. Build an Hexya module
- 4. Object-Relational Mapping
- 5. Resource files
- 6. Basic views
- 7. Synchronising the database and starting the server
- 8. Relations between models
- 9. Inheritance
- 10. Model methods
- 11. Computed fields and default values
- 12. Onchange
- 13. Model constraints
- 14. Advanced Views
- 15. Security
- 16. Wizards
- 17. Internationalization
- 18. Reporting
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/gleke/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/gleke/hexya-demo/openacademy",
"github.com/hexya-addons/web"
]
Note
|
All file paths in this tutorial are relative to the
|
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.
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.
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
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:
package openacademy
import (
"github.com/gleke/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. |
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.
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.
package openacademy
import (
"github.com/gleke/hexya/src/models"
)
func init() {
models.NewModel("OpenAcademyCourse")
}
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/gleke/h
is the package used to get model instances which are the entry point to accessing data. -
github.com/gleke/m
mainly defines the types of record sets and record data objects. -
github.com/gleke/q
is the query builder package of Hexya.
Important
|
|
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.
package openacademy
import (
"github.com/gleke/hexya/src/models"
"github.com/gleke/pool/h" // (1)
)
var fields_OpenAcademyCourse = map[string]models.FieldDefinition{
"Name": fields.Char{},
"Description": fields.Text{},
}
func init() {
models.NewModel("OpenAcademyCourse")
h.OpenAcademyCourse().AddFields(fields_OpenAcademyCourse)
}
-
Note that we need to import the generated
h
package to use it
Note
|
By convention fields definitions are declared as a package var named fields_Model .
|
Fields can be configured, by passing configuration attributes in the params struct:
"Name": fields.Char{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 (
SaleOrder
⇒Sale 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.
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
.
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
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:
<?xml version="1.0" encoding="utf-8"?>
<hexya>
<data>
</data>
</hexya>
Action, menus and view definitions will come inside the data
tag.
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):
(...)
<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. |
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.
(...)
<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. |
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).
A view is declared with the view
tag. The basic view types are: list, form
and search 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:
(...)
<view id="openacademy_course_tree" model="OpenAcademyCourse">
<tree>
<field name="Name"/>
</tree>
</view>
(...)
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:
(...)
<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:
<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>
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.
(...)
<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.
At this stage, we have created:
-
An
OpenAcademyCourse
model with two fieldsName
andDescription
-
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
|
|
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.
|
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
|
package openacademy
import (
"github.com/gleke/hexya/src/models"
"github.com/gleke/hexya/src/models/types"
"github.com/gleke/pool/h"
)
var fields_OpenAcademySession = map[string]models.FieldDefinition{
"Name": fields.Char{Required: true},
"StartDate": fields.Date{},
"Duration": fields.Float{Digits: nbutils.Digits{Precision: 6, Scale: 2},
Help: "Duration in days"},
"Seats": fields.Integer{String: "Number of seats"},
"State": fields.Selection{
Selection: types.Selection{"planned": "Planned", "in_progress":"In Progress", "done": "Finished"}}, //(2)
}
func init() { // (1)
models.NewModel("OpenAcademySession")
h.OpenAcademySession().AddFields(fields_OpenAcademySession)
}
-
You can define as many
init()
functions in a Go package -
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.
<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>
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.
var fields_SaleOrder = map[string]models.FieldDefinition{
"Customer": fields.Many2One{RelationModel: h.Partner()},
}
// -------------------------------------
fmt.Println(myOrder().Customer().Name())
- One2Many
-
A virtual relationship, inverse of a
Many2one
. AOne2many
behaves as a container of records, accessing its results in a (possibly empty) set of records.
var fields_SaleOrder = map[string]models.FieldDefinition{
"OrderLines": fields.One2Many{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.
var fields_SaleOrder = map[string]models.FieldDefinition{
"Tags": fields.Many2Many{RelationModel: h.Tag()},
}
// ---------------------------------------------
for _, tag := range myOrder().Tags().Records() {
fmt.Println(tag.Name())
}
Let’s add relations to our Course and Session models.
package openacademy
import (
"github.com/gleke/hexya/src/models"
"github.com/gleke/pool/h"
)
var fields_OpenAcademyCourse = map[string]models.FieldDefinition{
"Name": fields.Char{},
"Description": fields.Text{},
"Responsible": fields.Many2One{
RelationModel: h.User(), OnDelete: models.SetNull, Index: true},
"Sessions": fields.One2Many{
RelationModel: h.OpenAcademySession(), ReverseFK: "Course"},
}
func init() {
models.NewModel("OpenAcademyCourse")
h.OpenAcademyCourse().AddFields(fields_OpenAcademyCourse)
}
package openacademy
import (
"github.com/gleke/hexya/src/models"
"github.com/gleke/hexya/src/models/types"
"github.com/gleke/pool/h"
)
var fields_OpenAcademySession = map[string]models.FieldDefinition{
"Name": fields.Char{Required: true},
"StartDate": fields.Date{},
"Duration": fields.Float{Digits: nbutils.Digits{Precision: 6, Scale: 2},
Help: "Duration in days"},
"Seats": fields.Integer{String: "Number of seats"},
"State": fields.Selection{
Selection: types.Selection{"planned": "Planned", "in_progress":"In Progress", "done": "Finished"}}, //(2)
"Instructor": fields.Many2One{RelationModel: h.Partner()},
"Course": fields.Many2One{RelationModel: h.OpenAcademyCourse(),
Required: true, OnDelete: models.Cascade},
"Attendees": fields.Many2Many{RelationModel: h.Partner()},
}
func init() {
models.NewModel("OpenAcademySession")
h.OpenAcademySession().AddFields(fields_OpenAcademySession)
}
Now we update their views accordingly.
<?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>
<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>
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.
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.
<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 <xpath expr="//field[@name='Description']" position="after">
<field name="Ideas" />
</xpath>
<field name="Description" position="after">
<field name="Ideas" />
</field> |
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.
package openacademy
import (
"github.com/gleke/hexya/src/models"
"github.com/gleke/pool/h"
)
var fields_Partner = map[string]models.FieldDefinition{
"Instructor": fields.Boolean{},
"AttendedSessions": fields.Many2Many{RelationModel: h.OpenAcademySession()}
}
func init() {
h.Partner().AddFields(fields_Partner)
}
<?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>
With Hexya, you can define methods on models to implement business logic.
Methods are created by NewMethod()
:
import (
(...)
// We need to import m to define methods
"github.com/gleke/pool/m"
)
(...)
// StartSession sets the State of the Session to 'Started'
func openAcademyCourse_StartSession(rs m.OpenAcademyCourseSet) {
// Update State for all record in rs
rs.SetState("in_progress")
}
func init() {
(...)
h.OpenAcademyCourse().NewMethod("StartSession", openAcademyCourse_StartSession)
}
// calling the method somewhere else
myCourseSet.StartSession()
Note
|
By convention method functions are named model_MethodName (e.g. partner_UpdateBirthday ).
|
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.
h.OpenAcademyCourse().Methods().StartSession().Extend(
func(rs m.OpenAcademyCourseSet) {
rs.Super().StartSession()
fmt.Println("Session started")
})
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
)
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.
var fields_OpenAcademySession = map[string]models.FieldDefinition{
(...)
"TakenSeats": fields.Float{
Compute: h.OpenAcademySession().Methods().ComputeTakenSeats(),
Depends: []string{"Seats", "Attendees"}},
(...)
}
// ComputeTakenSeats returns the percentage of taken seats in this session
func openAcademySession_ComputeTakenSeats(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
}
func init() {
(...)
h.OpenAcademySession().Methods().ComputeTakenSeats().DeclareMethod(openAcademySession_ComputeTakenSeats)
(...)
}
Now let’s add our new field in the form and tree views:
(...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"/>
(...)
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
|
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.
var fields_OpenAcademySession = map[string]models.FieldDefinition{
(...)
"StartDate": fields.Date{
Default: func(env models.Environment) interface{} {
return dates.Today()
},
},
"Active": fields.Boolean{Default: models.DefaultValue(true)},
(...)
}
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.
var fields_OpenAcademySession = map[string]models.FieldDefinition{
(...)
"Seats": fields.Integer{
String: "Number of seats",
OnChange: h.OpenAcademySession().Methods().VerifyValidSeats()},
"Attendees": fields.Many2Many{
RelationModel: h.Partner(),
OnChange: h.OpenAcademySession().Methods().VerifyValidSeats()},
(...)
}
// VerifyValidSeats checks that the number of seats is positive
// and resets it to zero otherwise
func openAcademySession_VerifyValidSeats (rs m.OpenAcademySessionSet) m.OpenAcademySessionData {
res := h.OpenAcademySession().NewData()
if rs.Seats() < 0 {
res.SetSeats(0)
}
return res
}
func init() {
(...)
h.OpenAcademySession().NewMethod("VerifyValidSeats", openAcademySession_VerifyValidSeats)
(...)
}
It is also possible to send a warning or a new domain to the UI when a field is changed:
Not yet implemented
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:
import (
(...)
"github.com/gleke/pool/q"
)
var fields_OpenAcademySession = map[string]models.FieldDefinition{
(...)
"Instructor": fields.Many2One{RelationModel: h.Partner(),
Filter: q.Partner().Instructor().Equals(true)},
(...)
}
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
var fields_OpenAcademyCourse = map[string]models.FieldDefinition{
(...)
"Name": fields.Char{Unique: true},
(...)
}
func init() {
(...)
h.OpenAcademyCourse().AddSQLConstraint("name_description_check",
"CHECK(name != description)",
"The title of the course should not be the description")
(...)
}
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.
var fields_OpenAcademySession = map[string]models.FieldDefinition{
(...)
"Instructor": fields.Many2One{RelationModel: h.Partner(),
Filter: q.Partner().Instructor().Equals(true),
Constraint: h.OpenAcademySession().Methods().CheckInstructorNotInAttendees()},
"Attendees": fields.Many2Many{RelationModel: h.Partner(),
OnChange: h.OpenAcademySession().Methods().VerifyValidSeats(),
Constraint: h.OpenAcademySession().Methods().CheckInstructorNotInAttendees()},
(...)
}
// CheckInstructorNotInAttendees checks that the instructor is not present
// in the attendees of his/her own session
func openAcademySession_CheckInstructorNotInAttendees(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")
}
}
}
func init() {
(...)
h.OpenAcademySession().NewMethod("CheckInstructorNotInAttendees", openAcademySession_CheckInstructorNotInAttendees)
(...)
}
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 areuid
(the id of the current user) andcurrent_date
(the current date as a string of the formyyyy-MM-dd
).NoteEach 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:
DescriptionHTML
⇒description_html
-
Many2One fields are appended
_id
:Partner
⇒partner_id
-
One2Many and Many2many are appended
_ids
:OrderLines
⇒order_lines_ids
ImportantAll 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 bebf
(font-weight: bold
),it
(font-style: italic
), or any bootstrap contextual color (http://getbootstrap.com/components/#available-variations:danger
,info
,muted
,primary
,success
orwarning
).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<5" decoration-danger="duration>15">
<field name="Name"/>
<field name="Course"/>
<field name="Duration" invisible="1"/>
<field name="TakenSeats" widget="progressbar"/>
</tree>
</view>
(...)
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.
(...)
<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}'/>
(...)
Access control mechanisms must be configured to achieve a coherent security policy.
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.
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
package openacademy
import (
"github.com/gleke/hexya/src/models/security"
"github.com/gleke/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() {},
})
}
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)
(...)
}
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 theSettings
-
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
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
andattrs
attributes of view definitions. -
Through the
InvisibleFunc
andReadOnlyFunc
field attributes in the field declaration. In this case, the result applies to all views.
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
andrequired
. 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)]}"/>
(...)
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. |
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.
import (
(...)
"github.com/gleke/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)
(...)
}
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.
package openacademy
import (
"github.com/gleke/hexya/src/models"
"github.com/gleke/pool/h"
"github.com/gleke/pool/q"
)
var fields_OpenAcademyWizard = map[string]models.FieldDefinition{
"Session": fields.Many2One{RelationModel: h.OpenAcademySession(),
Required: true},
"Attendees": fields.Many2Many{RelationModel: h.Partner()},
}
func init() {
models.NewTransientModel("OpenAcademyWizard")
h.OpenAcademyWizard().AddFields(fields_OpenAcademyWizard)
}
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
var fields_OpenAcademyWizard = map[string]models.FieldDefinition{
(...)
"Session": fields.Many2One{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))
}
},
(...)
}
// Subscribe subscribes the users to the session
func openAcademyWizard_Subscribe(rs m.OpenAcademyWizardSet) {
rs.Session().SetAttendees(rs.Session().Attendees().Union(rs.Attendees()))
}
func init() {
(...)
h.OpenAcademyWizard().NewMethod("Subscribe", openAcademyWizard_Subscribe)
(...)
}
<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>
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.