Skip to content

5.0 step10

Jean Cavallo edited this page Feb 15, 2019 · 5 revisions

Step 10 - Overriding modules

Now that we have a complete module, we are going to doing a "real-life" exemple of making another one, which will add new functionalities to it.

The goal of this step is to add a module which will make it possible to register a "user" of the library. This user should be able to borrow books, with a few constraints. We are going to see how to extend models, new wizards, searcher functions, etc. It will also refresh your memory with almost everything we saw earlier.

The way this step will be done slightly differs than the previous ones. It will be more like a continuous exercise, with the detailed answers given right after the question. To make the most of it, you should try to come up with a solution (when asked to) before looking at the answer. Remember that your answer and the solution can be different, but that will not make yours a bad answer. Try to understand what is done in the answer, and see if your solution covers all of its uses.

Model

We will first think about our model. Our objectives are:

  • Managing a list of users
  • Allowing those users to borrow books (actually, exemplaries of books)
  • We should be able to know which books are available
  • We should know if a user is late returning a book

Try to think about the list of models (only stored models) with their __name__

Solution

There should be two new models:

  • library.user: the users of the library. We could say that there may be some redundancy (like the name information) with library.author model. And indeed, in a real world we may want to have a base model for both (in tryton, there is a party module which provides this). For convenience we will make it a separate model here
  • library.user.checkout: this model will represent the action of a user borrowing a book.

The second one's "raison d'être" may not seem obvious, after all why not simply add a Many2One field from exemplary to user? Well, doing so will make it impossible to track the borrowing history of a user, as well as adding non static informations on the exemplary (like borrowing date for instance). The naming for checkout is not obvious. This is because:

  • library.book.exemplary.checkout is too long, and feels weird. It would probably be the second best choice
  • library.book.checkout sounds good, however in terms of model it will not be ideal. A book checkout is actually a book exemplary checkout. There is no direct link from checkout to book, so it could be error-inducing
  • library.checkout is not so bad, but a little too general

library.user.checkout conveys the meaning that the checkout will be strongly linked to a user, which is a real and useful information.


Now we will create the structure of our new module. Next to the modules/library folder, create a new folder named library_borrow. You should be able to initialize tryton.cfg, __init__.py, library.py, library.xml (leave it empty with just the <tryton> tags for now). Note that we name our files library because their content will extend the behavior of library related models. If it also contained, let's say, accounting data (by inheriting from the account module from tryton), account related overrides should probably take place in account named files.

Solution

tryton.cfg:

[tryton]
version = 5.0.0
depends:
    ir
    res
    library
xml:
    library.xml

We add the library module as a dependency of our module. This means that it will not be possible to install the library_borrow module without installing first the library module.

__init__.py

from trytond.pool import Pool

from . import library


def register():
    Pool.register(
        library.User,
        library.Checkout,
        module='library_borrow', type_='model')

No surprises here, you probably had it right (apart from the class names, which are not really important). Just make sure you properly set the module parameter to 'library_borrow'.

library.py

from trytond.model import ModelSQL, ModelView


__all__ = [
    'User',
    'Checkout',
    ]


class User(ModelSQL, ModelView):
    'Library User'
    __name__ = 'library.user'


class Checkout(ModelSQL, ModelView):
    'Checkout'
    __name__ = 'library.user.checkout'

No surprises either, you should have nailed it alright.

library.xml

<?xml version="1.0"?>
<tryton>
    <data>
    </data>
</tryton>

In order to be able to install this new module, we must add the relevant setup.py file. You can try to imagine something from what exists in the library module, but here is a working file:

setup.py

#!/usr/bin/env python3
# This file is part of Tryton.  The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.

import io
import os
import re
from configparser import ConfigParser
from setuptools import setup


def read(fname):
    return io.open(
        os.path.join(os.path.dirname(__file__), fname),
        'r', encoding='utf-8').read()


def get_require_version(name):
    if minor_version % 2:
        require = '%s >= %s.%s.dev0, < %s.%s'
    else:
        require = '%s >= %s.%s, < %s.%s'
    require %= (name, major_version, minor_version,
        major_version, minor_version + 1)
    return require


config = ConfigParser()
config.read_file(open('tryton.cfg'))
info = dict(config.items('tryton'))
for key in ('depends', 'extras_depend', 'xml'):
    if key in info:
        info[key] = info[key].strip().splitlines()
version = info.get('version', '0.0.1')
major_version, minor_version, _ = version.split('.', 2)
major_version = int(major_version)
minor_version = int(minor_version)
name = 'trytond_training_override'

download_url = 'http://downloads.tryton.org/%s.%s/' % (
    major_version, minor_version)
if minor_version % 2:
    version = '%s.%s.dev0' % (major_version, minor_version)
    download_url = (
        'hg+http://hg.tryton.org/modules/%s#egg=%s-%s' % (
            name[8:], name, version))

requires = ['python-sql >= 0.9']
requires.append(get_require_version('trytond'))
requires.append(get_require_version('trytond_training'))

tests_require = [get_require_version('proteus')]
dependency_links = []
if minor_version % 2:
    dependency_links.append('https://trydevpi.tryton.org/')

setup(name=name,
    version=version,
    description='Training module for Tryton',
    author='Tryton',
    author_email='[email protected]',
    url='http://www.tryton.org/',
    download_url=download_url,
    keywords='tryton training library',
    package_dir={'trytond.modules.library_borrow': '.'},
    packages=[
        'trytond.modules.library_borrow',
        ],
    package_data={
        'trytond.modules.library_borrow': (info.get('xml', [])
            + ['tryton.cfg', 'view/*.xml', 'locale/*.po', 'icons/*.svg',
            'tests/*.rst']),
        },
    platforms='any',
    license='GPL-3',
    python_requires='>=3.4',
    install_requires=requires,
    dependency_links=dependency_links,
    zip_safe=False,
    entry_points="""
    [trytond.modules]
    library_borrow = trytond.modules.library_borrow
    """,
    )

Now we can install the module by (remember step 1):

# Install the module
pip install --editable /path/to/library_borrow/setup.py

# Register the new module in the database
trytond-admin -c /path/to/trytond.conf -d training -u ir

# Install it
trytond-admin -c /path/to/trytond.conf -d training -u library_borrow

You should stop / restart the trytond server for changes to take effect.

Relations

Now we will add relations to our newly created models. Think about it, which are the "pure relation" fields that will be needed? We are talking XXXtoXXX here.

Solution

  • A user has a One2Many of checkouts
  • A checkout has a Many2One (structural) field to a user
  • It also has another Many2One, structural as well, to a book exemplary
  • A book exemplary has a One2Many field of checkouts

The last one is actually optional, but we will use it for training purpose later.


Update library.py with those fields. Leave the last one (One2Many of checkouts on library.book.exemplary) for later.

Solution

imports:

from trytond.model import ModelSQL, ModelView, fields

library.user:

checkouts = fields.One2Many('library.user.checkout', 'user', 'Checkouts')

library.user.checkout:

user = fields.Many2One('library.user', 'User', required=True,
    ondelete='CASCADE', select=True)
exemplary = fields.Many2One('library.book.exemplary', 'Exemplary',
    required=True, ondelete='CASCADE', select=True)

You will note our using of select here, because there could be many checkouts in a real life database, and being able to filter them by user will need to be fast.

The ondelete values can be discussed at length, there are arguments for both CASCADE and RESTRICT. Here the idea is that a user's (or exemplary's) deletion is allowed, associated borrowings should be deleted as well.


For now, you already saw everything before. Now we are going to actually make some "module extension", by adding the checkouts field on book exemplaries. Add the following import in library.by:

from trytond.pool import PoolMeta

And now:

class Exemplary(metaclass=PoolMeta):
    __name__ = 'library.book.exemplary'

    checkouts = fields.One2Many('library.user.checkout', 'exemplary',
        'Checkouts')

Add the class to __all__ and __init__.py as usual.

So, there are a few differences here compared to the other models above. First of all, our Exemplary class does not inherit from anything. And there is this weird metaclass parameter here. Well, actually, the two are linked. The metaclass of a python class is its class. The class of the "class" object (more general informations on what a metaclass is here).

PoolMeta is provided by tryton, with a specific purpose in mind. Remember how we explained that the __name__ was very very important in tryton? Well here is one of the reason: here the PoolMeta will use it to work some magic, whose result will be that at runtime, this Exemplary class will inherit (in the python sense) of the Exemplary class we defined in the library module. It will do so because their __name__ is the same (we could have named this particular class MontyPython, it would have worked the same as long as the __name__ is the same).

But why not directly inherit from it:

from trytond.modules.library import library

class Exemplary(library.Exemplary):

The reason is that tryton is modular, and you do not want to force the inheritance because it will depend on which modules are installed and which are not.

Consider the case with the following modules:

                 ┌──────────┐
                 │ module_a │
                 └──────────┘
                 /          \
                /            \
               /              \
        ┌──────────┐       ┌──────────┐
        │ module_b │       │ module_c │
        └──────────┘       └──────────┘

All modules have a Exemplary class, with the same __name__. Logically, you would have the module_b and module_c version inherit from the module_a class. However, you will not be able to easily have a class which contains the data of all three modules.

The code we wrote above for the Exemplary class does it for us. You just have to trust it (the internals are interesting, but outside the scope of this training module).

The result now, is that we successfully added a new field to a model that was originally defined in the library module.

Data

Now we will work on fleshing up our new models a little. Think about what real fields should be added (meaning, no Function fields for now). There is no "good answer" here, as long as there is no information duplication.

Solution

  • User name
  • User registration date
  • Checkout date
  • Checkout return data

Fundamentally, those are the sole "required" field to properly manage a library (well actually, only the dates on the checkout model, but a user without a name is rather abstract, and registration date is a nice addition).


Well, now please go ahead an add them. Leave domains / states out for now

Solution

library.user:

name = fields.Char('Name', required=True)
registration_date = fields.Date('Registration Date', help='The date at '
    'which the user registered in the library')

library.user.checkout:

date = fields.Date('Date', required=True)
return_date = fields.Date('Return Date')

Notice the required=True on date, which is the most important here.


Function fields definitions

Now let's add more data, but calculated. There are many things that can be done here, try to think about what would be important in terms of library management, or useful in the user interface. Are they classmethod, do they need a searcher?

Solution

  • Number of books a user has currently checked out (classmethod, no searcher)
  • Number of books a user is late to give back (classmethod, no searcher)
  • Date at which a user should have returned its books (classmethod, searcher)
  • The expected return date of a checkout (assuming that there is a 20 days time limit) (no classmethod, searcher)
  • If an exemplary is available (classmethod, searcher)
  • If a book is available (classmethod, searcher)

The classmethod fields are those that can only be calculated through iterating over a list. The searcher fields are mostly a reason of convenience, because those are fields we will want to use as constraints, or important information.

Knowing if a user is late is important, we will want to for instance have a separate entry point to see them, which means being able to search on the date at which he should have.

Knowing if an exemplary is available is important, but knowing if a book is is more important, because the user does not really care about which exemplary he got as long as he got one.


You should be able to define those fields (just their definition for now).

Solution

library.user:

checkedout_books = fields.Function(
    fields.Integer('Checked-out books', help='The number of books a user '
        'has currently checked out'),
    'getter_checkedout_books')
late_checkedout_books = fields.Function(
    fields.Integer('Late checked-out books', help='The number of books a '
        'user is late returning'),
    'getter_checkedout_books')
expected_return_date = fields.Function(
    fields.Date('Expected return date', help='The date at which the user '
        'is (or was) expected to return his books'),
    'getter_checked_out_books', searcher='search_expected_return_date')

Note that we plan to use the same getter for all three fields. The reason is that the base of the query will be the same (the check-outs that are not yet returned for the user), only the returned information will differ.

library.user.checkout:

expected_return_date = fields.Function(
    fields.Date('Expected return date', help='The date at which the '
        'exemplary is supposed to be returned'),
    'getter_expected_return_date')

library.book:

class Book(metaclass=PoolMeta):
    __name__ = 'library.book'

    is_available = fields.Function(
        fields.Boolean('Is available', help='If True, at least an exemplary '
            'of this book is currently available for borrowing'),
        'getter_is_available', searcher='search_is_available')

New override of model library.book, this should be easy enough since there are no syntax quirks.

library.book.exemplary:

is_available = fields.Function(
    fields.Boolean('Is available', help='If True, the exemplary is '
        'currently available for borrowing'),
    'getter_is_available', searcher='search_is_available')

Now please implement the simplest getter (i.e. the instance method) for the expected_return_date field on a library.user.checkout.

Solution

def getter_expected_return_date(self, name):
    return self.date + datetime.timedelta(days=20)

There is no need to check for the value of self.date since the date field is required. The only "bad" case would be if the value of self.date was too close to the maximum date, we will handle this case when adding constraints.


For now we will not write the getter and searcher of our Function fields. This is because doing it "blind" (i.e. without having the possibility to check their behavior) is close to impossible, so we will write some UI first. However, we will have to set up "dummy" methods, so that the client does not crash when trying to read those fields.

Populate the models with getters / searchers which return "default" values.

Solution

Remember that the return value of a Function field with a classmethod getter is a dictionary which maps the return value to the id of all instances given as a parameter. That of a searcher is a domain which will be used when searching (...), so an empty [] will be enough for now.

library.user:

@classmethod
def getter_checkedout_books(cls, users, name):
    return {x.id: None for x in users}

@classmethod
def search_expected_return_date(cls, name, clause):
    return []

None is a valid return value for all Function fields.

library.user.checkout:

@classmethod
def search_expected_return_date(cls, name, clause):
    return []

library.book:

@classmethod
def getter_is_available(cls, books, name):
    return {x.id: None for x in books}

@classmethod
def search_is_available(cls, name, clause):
    return []

library.book.exemplary:

@classmethod
def getter_is_available(cls, exemplaries, name):
    return {x.id: None for x in exemplaries}

Views

We will now define some views for our models. Think of what you would want to see in our application.

Solution

  • A new entry points for users
  • A relate from users to show its checkouts (separated in "current" and "past")
  • Another entry point for late users
  • A relate to show checkouts on a book (not exemplaries, which is more of a "technical" information that is not relevant to the library user)
  • Modifications to the existing book / exemplary views to show availability informations

You should be able to do all of those yourself by reading back if needed, except for the last one which will cover right after. Fill up library.xml and create the associated views.

Note: This is going to be a lot of xml, so copy pasting is acceptable. However, take the time to properly read each line, and make sure you understand what it does

Solution

library.xml

<?xml version="1.0"?>
<tryton>
    <data>
        <!-- ######### -->
        <!-- # Users # -->
        <!-- ######### -->
        <record model="ir.ui.view" id="user_view_form">
            <field name="model">library.user</field>
            <field name="type">form</field>
            <field name="name">user_form</field>
        </record>
        <record model="ir.ui.view" id="user_view_list">
            <field name="model">library.user</field>
            <field name="type">tree</field>
            <field name="name">user_list</field>
        </record>
        <record model="ir.action.act_window" id="act_open_user">
            <field name="name">Users</field>
            <field name="res_model">library.user</field>
        </record>
        <record model="ir.action.act_window.view" id="act_open_user_view_list">
            <field name="sequence" eval="10"/>
            <field name="view" ref="user_view_list"/>
            <field name="act_window" ref="act_open_user"/>
        </record>
        <record model="ir.action.act_window.view" id="act_open_user_view_form">
            <field name="sequence" eval="20"/>
            <field name="view" ref="user_view_form"/>
            <field name="act_window" ref="act_open_user"/>
        </record>
        <menuitem parent="library.menu_library" sequence="30" action="act_open_user" id="menu_open_user"/>
        <record model="ir.action.act_window" id="act_open_late_users">
            <field name="name">Late Users</field>
            <field name="res_model">library.user</field>
            <field name="domain" eval="[('expected_return_date', '&lt;', Date())]" pyson="1"/>
        </record>
        <record model="ir.action.act_window.view" id="act_open_late_users_view_list">
            <field name="sequence" eval="10"/>
            <field name="view" ref="user_view_list"/>
            <field name="act_window" ref="act_open_late_users"/>
        </record>
        <record model="ir.action.act_window.view" id="act_open_late_users_view_form">
            <field name="sequence" eval="20"/>
            <field name="view" ref="user_view_form"/>
            <field name="act_window" ref="act_open_late_users"/>
        </record>
        <menuitem parent="library.menu_library" sequence="40" action="act_open_late_users" id="menu_open_late_users"/>
        <!-- ############ -->
        <!-- # Checkout # -->
        <!-- ############ -->
        <record model="ir.ui.view" id="user_checkout_view_form">
            <field name="model">library.user.checkout</field>
            <field name="type">form</field>
            <field name="name">user_checkout_form</field>
        </record>
        <record model="ir.ui.view" id="user_checkout_view_list">
            <field name="model">library.user.checkout</field>
            <field name="type">tree</field>
            <field name="name">user_checkout_list</field>
        </record>
        <record model="ir.action.act_window" id="act_open_user_checkout">
            <field name="name">Checkouts</field>
            <field name="res_model">library.user.checkout</field>
        </record>
        <record model="ir.action.act_window.view" id="act_open_user_checkout_view_list">
            <field name="sequence" eval="10"/>
            <field name="view" ref="user_checkout_view_list"/>
            <field name="act_window" ref="act_open_user_checkout"/>
        </record>
        <record model="ir.action.act_window.view" id="act_open_user_checkout_view_form">
            <field name="sequence" eval="20"/>
            <field name="view" ref="user_checkout_view_form"/>
            <field name="act_window" ref="act_open_user_checkout"/>
        </record>
        <!-- ########### -->
        <!-- # Relates # -->
        <!-- ########### -->
        <!-- User -> Checkouts relate -->
        <record model="ir.action.act_window" id="act_open_user_checkouts">
            <field name="name">Checkouts</field>
            <field name="res_model">library.user.checkout</field>
            <field name="domain" eval="[('user', '=', Eval('active_id'))]" pyson="1"/>
        </record>
        <record model="ir.action.act_window.view" id="act_open_user_checkouts_view1">
            <field name="sequence" eval="1"/>
            <field name="view" ref="user_checkout_view_list"/>
            <field name="act_window" ref="act_open_user_checkouts"/>
        </record>
        <record model="ir.action.act_window.view" id="act_open_user_checkouts_view2">
            <field name="sequence" eval="2"/>
            <field name="view" ref="user_checkout_view_form"/>
            <field name="act_window" ref="act_open_user_checkouts"/>
        </record>
        <record model="ir.action.act_window.domain" id="act_user_checkout_domain_ongoing">
            <field name="name">Ongoing</field>
            <field name="sequence" eval="10"/>
            <field name="domain" eval="[('return_date', '=', None)]" pyson="1"/>
            <field name="act_window" ref="act_open_user_checkouts"/>
        </record>
        <record model="ir.action.act_window.domain" id="act_user_checkout_domain_all">
            <field name="name">All</field>
            <field name="sequence" eval="20"/>
            <field name="act_window" ref="act_open_user_checkouts"/>
        </record>
        <record model="ir.action.keyword" id="act_open_user_checkouts_keyword1">
            <field name="keyword">form_relate</field>
            <field name="model">library.user,-1</field>
            <field name="action" ref="act_open_user_checkouts"/>
        </record>
        <!-- Book -> Checkouts relate -->
        <record model="ir.action.act_window" id="act_open_book_checkouts">
            <field name="name">Checkouts</field>
            <field name="res_model">library.user.checkout</field>
            <field name="domain" eval="[('exemplary.book', '=', Eval('active_id'))]" pyson="1"/>
        </record>
        <record model="ir.action.act_window.view" id="act_open_book_checkouts_view1">
            <field name="sequence" eval="1"/>
            <field name="view" ref="user_checkout_view_list"/>
            <field name="act_window" ref="act_open_book_checkouts"/>
        </record>
        <record model="ir.action.act_window.view" id="act_open_book_checkouts_view2">
            <field name="sequence" eval="2"/>
            <field name="view" ref="user_checkout_view_form"/>
            <field name="act_window" ref="act_open_book_checkouts"/>
        </record>
        <record model="ir.action.act_window.domain" id="act_book_checkout_domain_ongoing">
            <field name="name">Ongoing</field>
            <field name="sequence" eval="10"/>
            <field name="domain" eval="[('return_date', '=', None)]" pyson="1"/>
            <field name="act_window" ref="act_open_book_checkouts"/>
        </record>
        <record model="ir.action.act_window.domain" id="act_book_checkout_domain_all">
            <field name="name">All</field>
            <field name="sequence" eval="20"/>
            <field name="act_window" ref="act_open_book_checkouts"/>
        </record>
        <record model="ir.action.keyword" id="act_open_book_checkouts_keyword1">
            <field name="keyword">form_relate</field>
            <field name="model">library.book,-1</field>
            <field name="action" ref="act_open_book_checkouts"/>
        </record>
    </data>
</tryton>

There are two interesting things that you should note here.

  • In the act_open_late_users record, we use &lt; rather than <. This is because < (as well as >) are special characters in xml files, and must be repaced by &lt; and &gt; respectively
  • In act_open_book_checkouts, the domain we use is [('exemplary.book', '=', Eval('id'))]. We already talked about this in the constraint step: It is possible to "follow" Many2One fields in domains. Here, we want to get the list of all checkouts on any exemplary of the currently selected book. We could have done it by creating a Function field book on the library.checkout model, but this way is easier

view/user_list.xml

<?xml version="1.0"?>
<tree>
    <field name="name" expand="1"/>
    <field name="registration_date"/>
    <field name="expected_return_date"/>
    <field name="checkedout_books"/>
</tree>

view/user_form.xml

<?xml version="1.0"?>
<form>
    <label name="name"/>
    <field name="name" colspan="3"/>
    <label name="registration_date"/>
    <field name="registration_date" colspan="3"/>
    <label name="checkedout_books"/>
    <field name="checkedout_books"/>
    <label name="late_checkedout_books"/>
    <field name="late_checkedout_books"/>
    <label name="expected_return_date"/>
    <field name="expected_return_date"/>
</form>

view/user_checkout_list.xml

<?xml version="1.0"?>
<tree>
    <field name="user" expand="1"/>
    <field name="exemplary" expand="1"/>
    <field name="date"/>
    <field name="expected_return_date"/>
    <field name="return_date"/>
</tree>

view/user_checkout_form.xml

<?xml version="1.0"?>
<form>
    <label name="user"/>
    <field name="user" colspan="3"/>
    <label name="exemplary"/>
    <field name="exemplary" colspan="3"/>
    <label name="date"/>
    <field name="date"/>
    <label name="expected_return_date"/>
    <field name="expected_return_date"/>
    <label name="return_date"/>
    <field name="return_date"/>
</form>

Now let's work on "improving" a view that was declared in the previous library module.

Open the library.xml file and add the following:

<!-- ######## -->
<!-- # Book # -->
<!-- ######## -->
<record model="ir.ui.view" id="book_view_form">
    <field name="model">library.book</field>
    <field name="inherit" ref="library.book_view_form"/>
    <field name="name">book_form</field>
</record>

Looks similar to "normal" view, however:

  • The type field is not set
  • The inherit field is

Inherited views do not need a type since they are improving an existing view, whose type is already set. This is another reason to properly name your xml entities (if the id here was book_view, we would have no way to know whether it is a form or tree view).

The inherit field is a reference to the view of which this one inherits. ref contains a reference to an existing xml-defined record. library is the module, book_view_form the xml id of the instance in this module. Here, the module part is mandatory, because it is not the "current" module. So we are basically saying here that our view will extend the existing form view for library.book that we defined in our first module.

View inheritance in tryton is more completion than inheritance. Meaning that what we are currently doing is not creating a new view which will start as a copy of the existing one, but rather modifying the original view. This allows to keep one view (which we can refer to), with any number of modifications on this view (each module being able to modify it).

Fill up view/book_form.xml:

<?xml version="1.0"?>
<data>
    <xpath expr="/form/notebook/page[@id='other_data']" position="inside">
        <label name="is_available"/>
        <field name="is_available"/>
    </xpath>
</data>

So the structure here is a little different than what we already saw. The header is the same, but our view is actually <data>.

The xml file which describes an inheriting view is actually the definition of a list of modifications to the inherited view. Hence the <data> tag. Each of those modification is defined by a <xpath> tag. If you do not know about xpath, you can read about it here. Tryton has its own documentation here.

Basically, the expr tag is used to specify "somewhere" in the view, where we want our modification to happen. /form/notebook/page[@id='other_data'] means: in the form tag, in the notebook tag, find a page tag which has an id attrbute set to other_data. With this, you have almost all the cases covered, though there are subtleties. For instance, if for some reason expr matches multiple times, the modification will be made at all matches. So using simplified paths (//page[@id='other_dataè]), though simpler, can cause unexepected problems.

The position parameter is used to explain where the modification should be made relative to the position we found with expr:

  • inside: typically for group or form attributes, the contents of the <xpath> tag will be added "inside" the main tag
  • after / before: contents will be added after or before the selected tag
  • replace is often used to "remove" nodes in the inherited xml. For instance if you replace an existing field with two more detailed fields, you will want to replace the original field in the view with the two fields you created
  • replace_attributes: you want to modify some of the base attributes of the node. Typical use would be to change the colspan attribute

So here, we are saying to take the existing other_data page and add the is_available field data in it:

<form>
    ...
    <notebook>
        ...
        <page id="other_data" ...>
            ...
            <label name="is_available"/>
            <field name="is_available"/>
        </page>
    </notebook>
</form>

So now that you are know how to, add the is_available field in the book list view, as well as in the two exemplary views.

Solution

Right after our inherited form view:

<record model="ir.ui.view" id="book_view_list">
    <field name="model">library.book</field>
    <field name="inherit" ref="library.book_view_list"/>
    <field name="name">book_list</field>
</record>
<!-- ############# -->
<!-- # Exemplary # -->
<!-- ############# -->
<record model="ir.ui.view" id="exemplary_view_form">
    <field name="model">library.book.exemplary</field>
    <field name="inherit" ref="library.exemplary_view_form"/>
    <field name="name">exemplary_form</field>
</record>
<record model="ir.ui.view" id="exemplary_view_list">
    <field name="model">library.book.exemplary</field>
    <field name="inherit" ref="library.exemplary_view_list"/>
    <field name="name">exemplary_list</field>
</record>

view/book_list.xml

<?xml version="1.0"?>
<data>
    <xpath expr="/tree/field[@name='title']" position="before">
        <field name="is_available"/>
    </xpath>
</data>

view/exemplary_form.xml

<?xml version="1.0"?>
<data>
    <xpath expr="/form/field[@name='acquisition_price']" position="after">
        <label name="is_available"/>
        <field name="is_available"/>
    </xpath>
</data>

view/exemplary_list.xml

<?xml version="1.0"?>
<data>
    <xpath expr="/tree/field[@name='rec_name']" position="before">
        <field name="is_available"/>
    </xpath>
</data>

View inheritance can be picky at times, but you will have to gain some understanding of how it works if you ever want to build new modules upon existing ones.

Function fields getters

It is now time to write those getters / searchers. We took the time to actually write entry points and views so that you can check them out as you write them. The idea there is that you will take the time to try writing those fields, one by one, and testing them until they look to be working. Of cours, feel free to look at the solution if it takes too long, doing so should help you with the following field.

First, the getter of is_available on library.book.exemplary

Solution

from sql import Null

from trytond.pool import PoolMeta, Pool
from trytond.transaction import Transaction

# ...


@classmethod
def getter_is_available(cls, exemplaries, name):
    checkout = Pool().get('library.user.checkout').__table__()
    cursor = Transaction().connection.cursor()
    result = {x.id: True for x in exemplaries}
    cursor.execute(*checkout.select(checkout.exemplary,
            where=(checkout.return_date == Null)
            & checkout.exemplary.in_([x.id for x in exemplaries])))
    for exemplary_id, in cursor.fetchall():
        result[exemplary_id] = False
    return result

The logic is to say that all exemplaries are available, but those for which there is at least one currently running (checkout.return_date == Null) checkout.


Now we can go ahead with the slightly more difficult getter for the three fields of library.user. We defined the same getter for all of those, and will rely on the value of the name parameter to identify them.

Solution

from sql.aggregate import Count, Min

# ...


@classmethod
def getter_checkedout_books(cls, users, name):
    checkout = Pool().get('library.user.checkout').__table__()
    cursor = Transaction().connection.cursor()
    default_value = None
    if name not in ('checkedout_books', 'late_checkedout_books'):
        default_value = 0
    result = {x.id: default_value for x in users}
    column, where = None, None
    if name == 'checkedout_books':
        column = Count(checkout.id)
        where = checkout.return_date == Null
    elif name == 'late_checkedout_books':
        column = Count(checkout.id)
        where = (checkout.return_date == Null) & (
            checkout.date < datetime.date.today() +
            datetime.timedelta(days=20))
    elif name == 'expected_return_date':
        column = Min(checkout.date)
        where = checkout.return_date == Null
    else:
        raise Exception('Invalid function field name %s' % name)
    cursor.execute(*checkout.select(checkout.user, column,
            where=where & checkout.user.in_([x.id for x in users]),
            group_by=[checkout.user]))
    for user_id, value in cursor.fetchall():
        result[user_id] = value
        if name == 'expected_return_date' and value:
            result[user_id] += datetime.timedelta(days=20)
    return result

This can be made easier to read by separating the function as follow:

@classmethod
def getter_checkedout_books(cls, users, name):
    checkout = Pool().get('library.user.checkout').__table__()
    cursor = Transaction().connection.cursor()
    default_value = None
    if name not in ('checkedout_books', 'late_checkedout_books'):
        default_value = 0
    result = {x.id: default_value for x in users}
    column, where = cls._get_checkout_column(checkout, name)
    cursor.execute(*checkout.select(checkout.user, column,
            where=where & checkout.user.in_([x.id for x in users]),
            group_by=[checkout.user]))
    for user_id, value in cursor.fetchall():
        result[user_id] = value
        if name == 'expected_return_date' and value:
            result[user_id] += datetime.timedelta(days=20)
    return result

@classmethod
def _get_checkout_column(cls, checkout_table, name):
    column, where = None, None
    if name == 'checkedout_books':
        column = Count(checkout_table.id)
        where = checkout_table.return_date == Null
    elif name == 'late_checkedout_books':
        column = Count(checkout_table.id)
        where = (checkout_table.return_date == Null) & (
            checkout_table.date < datetime.date.today() +
            datetime.timedelta(days=20))
    elif name == 'expected_return_date':
        column = Min(checkout_table.date)
        where = checkout_table.return_date == Null
    else:
        raise Exception('Invalid function field name %s' % name)
    return column, where

The underlying logic is easier to grasp this way. The getter contents are effectively the same for all names, only the constraint / returned column is different. Once this is separated from the main code, it becomes quite readable.


A little more complicated now is the getter for the is_available field on library.book. This one will require to join on tables because we want to look for books which have at least one exemplary, for which all checkouts are completed (or rather, at least one is not). You should try working directly in SQL to get the query right, then translate it to python in the getter.

Solution

Here the idea is to look for exemplaries which are available. It is easier to find one of those than to check if all exemplaries are borrowed.

@classmethod
def getter_is_available(cls, books, name):
    pool = Pool()
    checkout = pool.get('library.user.checkout').__table__()
    exemplary = pool.get('library.book.exemplary').__table__()
    book = cls.__table__()
    result = {x.id: False for x in books}
    cursor = Transaction().connection.cursor()
    cursor.execute(*book.join(exemplary,
            condition=(exemplary.book == book.id)
            ).join(checkout, 'LEFT OUTER',
            condition=(exemplary.id == checkout.exemplary)
            ).select(book.id,
            where=(checkout.return_date != Null) | (checkout.id == Null)))
    for book_id, in cursor.fetchall():
        result[book_id] = True
    return result

Note the 'LEFT OUTER', because we want to find exemplaries for which no checkouts were made.

For information, here is the associated SQL query:

SELECT DISTINCT "a"."id"
FROM "library_book" AS "a"
INNER JOIN "library_book_exemplary" AS "b" ON ("b"."book" = "a"."id")
LEFT OUTER JOIN "library_user_checkout" AS "c" ON (("b"."id" = "c"."exemplary") )
WHERE ("c"."return_date" IS NOT NULL)
  OR ("c"."id" IS NULL) ;

While you are here, something that could be useful is to add a way to search on the rec_name of library.book.exemplary. This will allow the user to find an exemplary by directly typing in its identifier, or book title. Indeed, rec_name is where default searches are made. Since we defined a custom getter for this field earlier, we could as well define a custom searcher. Please do so, it is an easy one.

Solution

@classmethod
def search_rec_name(cls, name, clause):
    return ['OR',
        ('identifier',) + tuple(clause[1:]),
        ('book.title',) + tuple(clause[1:]),
        ]

We will also do something a little more technical, which is allowing to sort on the rec_name of library.book.exemplary. This is slightly complex, and is not often done, so feel free to ignore this.

Defining a order method

The rec_name of an Exemplary is defined as the concatenation of the book title and the exemplary identifier. Here is the code that will allow to order this field:

from sql.operators import Concat

# In the "library.book.exemplary" class
@classmethod
def order_rec_name(cls, tables):
    exemplary, _ = tables[None]
    book = tables.get('book')

    if book is None:
        book = Pool().get('library.book').__table__()
        tables['book'] = {None: (book, book.id == exemplary.book)}

    return [Concat(book.title, exemplary.identifier)]

This calls for some explanations.

Ordering a field in Tryton can only be done by providing a SQL way to do it. This means that our order function must return a SQL expression that can be used in an ORDER BY clause. It will always be used in the context of a call to search.

Basically, when calling the search method with a domain, Tryton will call the (low-level) convert_domain method to transform this domain into a structure that can be used to express the domain as a SQL expression.

What we get in the tables parameter is a part of this structure, which represents the various tables that will be used in the search, as well as the relations (joins...) between them.

By convention, the None key in the structure will reference the "main" table of the query (the one corresponding to the model we are searching on). So the structure will look like something like:

{
    None: (main_table, None),
    'some_key': {
        None: (other_table, other_table.field_name = main_table.id),
        },
    ...
}

Here, since the rec_name includes the Book's title, we need to make sure we have a join on the library.book model. We do that by adding a new key in the structure, which will create this link:

tables['book'] = {None: (book, book.id == exemplary.book)}

Once this is done, we can actually express in SQL the ordering value we want to sort on (the ORDER BY clause contents) using the table we just added:

return [Concat(book.title, exemplary.identifier)]

So basically, order methods must:

  • Ensure the various tables that are needed to express the ordering condition (in general an approximation of the field value) are available
  • Return the actual ordering expression

You should now be able to order the exemplaries based on their rec_name by clicking the corresponding column in the client.

Function fields searchers

We are going to have to implement our searchers, because we will want to create constraints which use them. And remember, a domain constraint is applied by database queries, so elements in the domain must be searchable.

Let us start with a simple searcher: that of the expected_return_date field on library.user.checkout. This searcher is actually simple to write, because it does not require to use SQL.

The goal here is to convert the clause we will get as an input ( ('expected_return_date', '>', '2020-01-01')) and convert it to a clause on a "real" date. Try it, you can check how it behaves by searching in a Checkout action window on this field.

Solution

@classmethod
def search_expected_return_date(cls, name, clause):
    _, operator, value = clause
    if isinstance(value, datetime.date):
        value = value + datetime.timedelta(days=-20)
    if isinstance(value, (list, tuple)):
        value = [(x + datetime.timedelta(days=-20) if x else x)
            for x in value]
    return [('date', operator, value)]
_, operator, value = clause

will make operator hold (for instance) '>', and value the date 2020-01-01. We do not care about the first element in the clause, which we know to be 'expected_return_date'.

We then check the "type" of the value and update it according to the 20 days rule we set up. We need to check the type, because the clause could be != None (for instance), in which case the addition will fail.

The possible "types" for the value part are None, a list / tuple for the in or not in operators, or the type of the field (here a datetime.date), so we need to manage all those cases.


This searcher was not overly complex, because the getter itself is not complicated. However, we now have to see the case of a little more complex searcher. We will work with the expected_return_date on library.user. You could have thought that it would not be so different than the one on library.exemplary, but actually there is a big difference: it applies to an aggregated data, meaning we want to get the Minimum expected return date of non completed checkouts.

The general idea for searchers of this sort is to use a SQL query that properly computes the field value we want (that is why once a searcher is done, writing the associated SQL getter is easy, and the other way around as well), and to apply a SQL filter on this value:

from trytond.model.fields import SQL_OPERATORS

@classmethod
def searcher_my_field(cls, name, clause):
    _, operator, operand = clause
    Operator = SQL_OPERATORS[operator]
    query = my_table.select(my_table.id,
        where=...,
        group_by=my_table.id,
        having=Operator(my_value, operand))
    return [('id', 'in', query)]

Note that the operand part of a clause may be a SQL object (if the operator is one of in or not in).

Now, in our case here:

from trytond.model.fields import SQL_OPERATORS

@classmethod
def search_expected_return_date(cls, name, clause):
    user = cls.__table__()
    checkout = Pool().get('library.user.checkout').__table__()
    _, operator, value = clause
    if isinstance(value, datetime.date):
        value = value + datetime.timedelta(days=-20)
    if isinstance(value, (list, tuple)):
        value = [(x + datetime.timedelta(days=-20) if x else x)
            for x in value]
    Operator = SQL_OPERATORS[operator]

    query_table = user.join(checkout, 'LEFT OUTER',
        condition=checkout.user == user.id)

    query = query_table.select(user.id,
        where=(checkout.return_date == Null) |
        (checkout.id == Null),
        group_by=user.id,
        having=Operator(Min(checkout.date), value))
    return [('id', 'in', query)]

Ideally we could have done something like Min(checkout.date + 20), but it is not possible in standard python sql, so we must use the same trick that we used earlier.

Now we will go for another of our searchers: is_available, on library.book. Something interesting is that is_available is a boolean field, so basically there are two cases: either we search for True, or for False. Have a go at it, then check the solution.

Solution

@classmethod
def search_is_available(cls, name, clause):
    _, operator, value = clause
    if operator == '!=':
        value = not value
    pool = Pool()
    checkout = pool.get('library.user.checkout').__table__()
    exemplary = pool.get('library.book.exemplary').__table__()
    book = cls.__table__()
    query = book.join(exemplary,
        condition=(exemplary.book == book.id)
        ).join(checkout, 'LEFT OUTER',
        condition=(exemplary.id == checkout.exemplary)
        ).select(book.id,
        where=(checkout.return_date != Null) | (checkout.id == Null))
    return [('id', 'in' if value else 'not in', query)]

Here we only write the query for the "available" case, and change the clause from 'in' to 'not in' if we want the not available ones. You should be able to do the same for the library.book.exemplary version:

@classmethod
def search_is_available(cls, name, clause):
    _, operator, value = clause
    if operator == '!=':
        value = not value
    pool = Pool()
    checkout = pool.get('library.user.checkout').__table__()
    exemplary = cls.__table__()
    query = exemplary.join(checkout, 'LEFT OUTER',
        condition=(exemplary.id == checkout.exemplary)
        ).select(exemplary.id,
        where=(checkout.return_date != Null) | (checkout.id == Null))
    return [('id', 'in' if value else 'not in', query)]

Something that you must remember about searchers is that you do not have to cover all cases. Just make sure you explicitely raise an error if for some reason a case you did not want to handle arise. For instance we may want to cover the case (in our last searcher) of a developer calling the search with something like ('is_available', 'in', (True, False)) in which case our searcher will not crash, but not behave as expected either. That could be done by adding assert value in (True, False) at the beginning of the method.

Constraints

Now that our Function fields are searchable, we can start to use them in constraints. For now, just think about what you would want to have.

Solution

  • registration_date should be less than today
  • date as well
  • return_date should be less than today, and greater then date

...

That is rather light. Why do we not use our newly created Function fields to make interesting constraints like (for instance) forcing the exemplary of a checkout to be available ?

There is a very good reason for that. Domain constraints must be valid at all time. This means that the constraint we want to craft need to be valid when we create the checkout (this part is ok), but it must also be valid when the checkout is returned.


Try to implement those three domains.

Solution

registration_date

registration_date = fields.Date('Registration Date', domain=[
        If(~Eval('registration_date'), [],
            [('registration_date', '<=', Date())])],
    help='The date at which the user registered in the library')

date

date = fields.Date('Date', required=True, domain=[
        ('date', '<=', Date())])

return_date

return_date = fields.Date('Return Date', domain=[
        If(~Eval('return_date'), [],
            [('return_date', '<=', Date()),
                ('return_date', '>=', Eval('date'))])],
    depends=['date'])

Wizardry

The idea now is that borrowing or returning a book will be done through wizards. This will allow the user to be the most efficient possible. There will be:

  • A "borrow" wizard, that can be called from a user or an available book. It will allow to select the books he wants to borrow (provided they are available), and the borrow date (which should be initialized to today). It will tell the user the expected return date for the books
  • A "return" wizard, where a list of checkouts can be selected for a grouped return (with the possibility to set the return date). If no checkouts were selected (but just a user), all current checkouts are added by defaults, but can be removed if needed

Well, you should be able to do both of those. Start first with the "borrow" wizard.

Solution

wizard.py

import datetime

from trytond.pool import Pool
from trytond.model import ModelView, fields
from trytond.transaction import Transaction
from trytond.wizard import Wizard, StateView, StateTransition, StateAction
from trytond.wizard import Button
from trytond.pyson import Date, Eval, PYSONEncoder


__all__ = [
    'Borrow',
    'BorrowSelectBooks',
    ]


class Borrow(Wizard):
    'Borrow books'
    __name__ = 'library.user.borrow'

    start_state = 'select_books'
    select_books = StateView('library.user.borrow.select_books',
        'library_borrow.borrow_select_books_view_form', [
            Button('Cancel', 'end', 'tryton-cancel'),
            Button('Borrow', 'borrow', 'tryton-go-next', default=True)])
    borrow = StateTransition()
    checkouts = StateAction('library_borrow.act_open_user_checkout')

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls._error_messages.update({
                'unavailable': 'Exemplary %(exemplary)s is unavailable for '
                'checkout',
                })

    def default_select_books(self, name):
        user = None
        exemplaries = []
        if Transaction().context.get('active_model') == 'library.user':
            user = Transaction().context.get('active_id')
        elif Transaction().context.get('active_model') == 'library.book':
            books = Pool().get('library.book').browse(
                Transaction().context.get('active_ids'))
            for book in books:
                if not book.is_available:
                    continue
                for exemplary in book.exemplaries:
                    if exemplary.is_available:
                        exemplaries.append(exemplary.id)
                        break
        return {
            'user': user,
            'exemplaries': exemplaries,
            'date': datetime.date.today(),
            }

    def transition_borrow(self):
        Checkout = Pool().get('library.user.checkout')
        exemplaries = self.select_books.exemplaries
        user = self.select_books.user
        checkouts = []
        for exemplary in exemplaries:
            if not exemplary.is_available:
                self.raise_user_error('unavailable', {
                        'exemplary': exemplary.rec_name})
            checkouts.append(Checkout(
                    user=user, date=self.select_books.date,
                    exemplary=exemplary))
        Checkout.save(checkouts)
        self.select_books.checkouts = checkouts
        return 'checkouts'

    def do_checkouts(self, action):
        action['pyson_domain'] = PYSONEncoder().encode([
                ('id', 'in', [x.id for x in self.select_books.checkouts])])
        return action, {}


class BorrowSelectBooks(ModelView):
    'Select Books'
    __name__ = 'library.user.borrow.select_books'

    user = fields.Many2One('library.user', 'User', required=True)
    exemplaries = fields.Many2Many('library.book.exemplary', None, None,
        'Exemplaries', required=True, domain=[('is_available', '=', True)])
    date = fields.Date('Date', required=True, domain=[('date', '<=', Date())])
    checkouts = fields.Many2Many('library.user.checkout', None, None,
        'Checkouts', readonly=True)

Remember to register BorrowSelectBooks as a model and Borrow as a wizard in __init__.py!

wizard.xml

<?xml version="1.0"?>
<tryton>
    <data>
        <!-- ########## -->
        <!-- # Borrow # -->
        <!-- ########## -->
        <record model="ir.action.wizard" id="act_library_user_borrow">
            <field name="name">Borrow</field>
            <field name="wiz_name">library.user.borrow</field>
        </record>
        <record model="ir.action.keyword" id="act_library_user_borrow_keyword">
            <field name="keyword">form_action</field>
            <field name="model">library.user,-1</field>
            <field name="action" ref="act_library_user_borrow"/>
        </record>
        <record model="ir.action.keyword" id="act_library_book_borrow_keyword">
            <field name="keyword">form_action</field>
            <field name="model">library.book,-1</field>
            <field name="action" ref="act_library_user_borrow"/>
        </record>
        <!-- ####################### -->
        <!-- # Borrow Select Books # -->
        <!-- ####################### -->
        <record model="ir.ui.view" id="borrow_select_books_view_form">
            <field name="model">library.user.borrow.select_books</field>
            <field name="type">form</field>
            <field name="name">borrow_select_books_form</field>
        </record>
    </data>
</tryton>

Remember to add it in tryton.cfg. Note that we use two relates on different models which use the same action. We make the difference in the default_select_books methods based on the active_model to properly initialize the wizard.

view/borrow_select_books_form.xml

<?xml version="1.0"?>
<form>
    <label name="user"/>
    <field name="user" colspan="3"/>
    <label name="date"/>
    <field name="date" colspan="3"/>
    <field name="exemplaries" colspan="4"/>
    <field name="checkouts" colspan="4" invisible="1"/>
</form>

That's one wizard, can you try to make the second one as well?

Solution

wizard.py

class Return(Wizard):
    'Return'
    __name__ = 'library.user.return'

    start_state = 'select_checkouts'
    select_checkouts = StateView('library.user.return.checkouts',
        'library_borrow.return_checkouts_view_form', [
            Button('Cancel', 'end', 'tryton-cancel'),
            Button('Return', 'return_', 'tryton-go-next', default=True)])
    return_ = StateTransition()

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls._error_messages.update({
                'multiple_users': 'You cannot return checkouts from different '
                'users at once',
                'available': 'Cannot return an available exemplary',
                })

    def default_select_checkouts(self, name):
        Checkout = Pool().get('library.user.checkout')
        user = None
        checkouts = []
        if Transaction().context.get('active_model') == 'library.user':
            user = Transaction().context.get('active_id')
            checkouts = [x for x in Checkout.search([
                        ('user', '=', user), ('return_date', '=', None)])]
        elif (Transaction().context.get('active_model') ==
                'library.user.checkout'):
            checkouts = Checkout.browse(
                Transaction().context.get('active_ids'))
            if len({x.user for x in checkouts}) != 1:
                self.raise_user_error('multiple_users')
            if any(x.is_available for x in checkouts):
                self.raise_user_error('available')
            user = checkouts[0].user.id
        return {
            'user': user,
            'checkouts': [x.id for x in checkouts],
            'date': datetime.date.today(),
            }

    def transition_return_(self):
        Checkout = Pool().get('library.user.checkout')
        Checkout.write(list(self.select_checkouts.checkouts), {
                'return_date': self.select_checkouts.date})
        return 'end'


class ReturnSelectCheckouts(ModelView):
    'Select Checkouts'
    __name__ = 'library.user.return.checkouts'

    user = fields.Many2One('library.user', 'User', required=True)
    checkouts = fields.Many2Many('library.user.checkout', None, None,
        'Checkouts', domain=[('user', '=', Eval('user')),
            ('return_date', '=', None)])
    date = fields.Date('Date', required=True, domain=[('date', '<=', Date())])

Remember to add the models in __all__ and __init__.py!

wizard.xml

<!-- ########## -->
<!-- # Return # -->
<!-- ########## -->
<record model="ir.action.wizard" id="act_library_user_return">
    <field name="name">Return</field>
    <field name="wiz_name">library.user.return</field>
</record>
<record model="ir.action.keyword" id="act_library_user_return_keyword">
    <field name="keyword">form_action</field>
    <field name="model">library.user,-1</field>
    <field name="action" ref="act_library_user_return"/>
</record>
<record model="ir.action.keyword" id="act_library_checkout_return_keyword">
    <field name="keyword">form_action</field>
    <field name="model">library.user.checkout,-1</field>
    <field name="action" ref="act_library_user_return"/>
</record>
<!-- ########################### -->
<!-- # Return Select Checkouts # -->
<!-- ########################### -->
<record model="ir.ui.view" id="return_checkouts_view_form">
    <field name="model">library.user.return.checkouts</field>
    <field name="type">form</field>
    <field name="name">return_checkouts_form</field>
</record>

No real surprises here.

view/return_checkout_form.xml

<?xml version="1.0"?>
<form>
    <label name="user"/>
    <field name="user" colspan="3"/>
    <label name="date"/>
    <field name="date" colspan="3"/>
    <field name="checkouts" colspan="4"/>
</form>

Going further

The final code for this module is available in git branch 5.0/step10-completed.

As you can see, almost everything you did here in a new module is the same as doing it in the initial module. What you do need to remember is:

  • When extending a model, no python inheritance, just __name__ it properly and set the __metaclass__ to PoolMeta
  • When adding a new field to an existing model, just declare it in the override
  • If you need to modify a field in a sub module (which we actually did not do here), you must use the __setup__ method. For instance, modifying a domain on a field is done as follow:
class MyModel:
    __name__ = 'my.overriden.model'

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.my_field_name.domain = ['OR',
            my_field_name.domain, [('my_field', '>', 0)]]
        cls.my_field.string = 'My field'
        cls.readonly = True

Warning: You cannot remove the required attribute of a field, because the constraint is added in the database, and will be recreated every time you update the "parent" module

Final words

That's it for this training module. You should now be able to make your own modules, and understand existing modules by reading them.

Of course, there are more advanced features of tryton that we did not cover here, however they are seldom used. You should ask on the tryton mailing list about using them, or try to find them used in existing tryton modules, read the code, and make them your own.

Good luck