-
Notifications
You must be signed in to change notification settings - Fork 30
5.0 step10
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.
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 thename
information) withlibrary.author
model. And indeed, in a real world we may want to have a base model for both (in tryton, there is aparty
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.
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.
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.
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}
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', '<', 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<
rather than<
. This is because<
(as well as>
) are special characters in xml files, and must be repaced by<
and>
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 aFunction
fieldbook
on thelibrary.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 forgroup
orform
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 thecolspan
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.
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.
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.
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'])
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>
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__
toPoolMeta
- 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
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