Odoo

nora.nckm.eu

Illustration

Odoo est une suite d'applications Web pour l'entreprise. La documentation contient les références du framework ainsi qu'un tutoriel détaillé qui existe aussi en version condensée. Il existe deux versions : Odoo Enterprise (licensed & shared sources) and Odoo Community (open-source).

Architecture

L'architecture est très proche de celle d'un framework comme Django. Odoo suit le pattern Model View Template (MVT). Le backend est codé en Python et intègre un Object-Relational Mapping (ORM) sur une base de données PostgreSQL. Le frontend est codé en HTML5, Javascript, CSS grâce au langage de gabarits QWeb.

Les modules sont au cœur de l'architecture du système. Il est possible d'étendre ou d'ajouter des fonctionnalités au moyen de modules dont voici la structure :

module
├── models
│   ├── *.py
│   └── __init__.py
├── views
│   └── *.xml
├── report
│   └── *.xml
├── data
│   ├── *.csv
│   └── *.xml
├── security
│   └── *.csv
├── i18n
│   ├── module.pot
│   └── *.po
├── __init__.py
└── __manifest__.py

Environnement

La mise en place de l'environnement de développement consiste à :

  1. Installer une base PostgreSQL
  2. Installer Odoo depuis les sources
  3. Préparer un répertoire contenant nos modules

La procédure est détaillée dans le tutoriel officiel ou dans le readme du projet sur git.sr.ht.

Configuration

La configuration d'un module se fait dans le fichier __manifest__.py.

{
'name': 'surveyor',
'version': '0.1',
'category': 'Hidden',
'summary': 'Customize Odoo with surveyor specificities',
'description': "",
'author': 'nora',
'website': 'https://git.sr.ht/~nora/odoo-addons',
'depends': [
    'base',
    'event',
    'sale_management',
],
'data': [
    'security/ir.model.access.csv',
    'views/surveyor_site_views.xml',
    'report/surveyor_site_reports.xml',
    'report/surveyor_site_templates.xml',
    'views/sale_views.xml',
],
"demo": [
    "data/surveyor_demo.xml"
],
'application': False,
'installable': True,
'auto_install': False,
'license': 'LGPL-3',
}

On indique les métadonnées du module, les dépendances à d'autres modules, les fichiers à charger avec le module (données, vues, rapports...).

Attention

Ne pas oublier d'importer les modèles dans les fichiers :

# __init__.py
from . import models

# models/__init__.py
from . import surveyor_site, sale_order

Modèles

Les modèles sont définis dans le répertoire models.

# models/surveyor_site.py

from odoo import fields, models, api, _
from odoo.exceptions import ValidationError

class SurveyorSite(models.Model):
    _name = "surveyor.site"
    _description = "Site"
    _order = 'city_prefix, section_code, plot_code'
    _sql_constraints = [
        # ('check_percentage', 'check(percentage >= 0 and percentage <= 100)', 'The percentage should be between 0 and 100.'),
        ('address_uniq', 'unique(address)', "Address must be unique!"),
        ('plot_uniq', 'unique(city_prefix, section_code, plot_code)', "Cadastral plot must be unique!"),
    ]

    # name: display name
    # active: filter out inactive records
    # state: display buttons conditionally
    name = fields.Char(default="Unknown", compute='_compute_name')
    active = fields.Boolean(default=True)
    state = fields.Selection(default='in_progress', selection=[('in_progress', 'In Progress'), ('archived', 'Archived'), ('archived_real_estate', 'Archived Real Estate')])

    # Request access
    # self.env.cr or self._cr is the database cursor object for querying the database
    # self.env.uid or self._uid is the current user’s database id
    # self.env.user is the current user’s record
    # self.env.context or self._context is the context dictionary
    # self.env.ref(xml_id) returns the record corresponding to an XML id
    # self.env[model_name] returns an instance of the given model

    # Many2one (drop-down list)
    # site_type_id = fields.Many2one("surveyor.site.type", string="Site type")
    user_id = fields.Many2one('res.users', string='User', index=True, default=lambda self: self.env.user)
    partner_id = fields.Many2one('res.partner', string='Partner', index=True)
    # Many2many (multiple-choice list) ex: <field name="country_ids" widget="many2many_tags"/>
    # tag_ids = fields.Many2many("surveyor.site.tag", string="Sites tags")
    tag_ids = fields.Many2many("event.tag", string="Sites tags")
    tag_count = fields.Integer(compute='_compute_tag_count')
    # One2many (list)
    # sites_ids = fields.One2many("surveyor.site", "partner_id", string="Partner sites")

    address = fields.Char(required=True)
    city = fields.Char(size=50)
    city_code = fields.Char(help='Insee city code', size=5)
    city_prefix = fields.Char(size=3)
    section_code = fields.Char() # size=2
    plot_code = fields.Char(size=4)

    @api.depends("address")
    def _compute_name(self):
        for record in self:
            record.name = record.address

    @api.depends("tag_ids")
    def _compute_tag_count(self):
        for record in self:
            record.tag_count = len(record.tag_ids)

    # def _inverse_name(self):
    #     for record in self:
    #         record.address = record.name

    @api.constrains('section_code')
    def _check_section_code(self):
        for record in self:
            tmp = re.match(r"^[A-Z0-9]{2}(,[A-Z0-9]{2})*$", record.section_code)
            if not re.match(r"^[A-Z0-9]{2}(,[A-Z0-9]{2})*$", record.section_code):
                raise ValidationError(_("Field section_code must contain a comma-separated list of two-character strings (AI,AS)"))

    @api.onchange('section_code')
    def _onchange_section_code(self):
        if self.section_code:
            self.section_code = ",".join(set(filter(None, re.split(r"\s*,\s*|\s+", str.upper(self.section_code)))))
            return {
                'warning': {'title': "Warning", 'message': f"section_code change to {self.section_code}", 'type': 'notification'},
            }

    @api.ondelete(at_uninstall=False)
    def _ondelete(self):
        for record in self:
            if record.state in ("archived", "archived_real_estate"):
                raise ValidationError(_("You can not delete an archived site!"))

    @api.model
    def create(self, vals):
        if not vals['city_code'].endswith(vals['city_prefix']):
            raise ValidationError(_("City prefix must match City code!"))
        return super().create(vals)

    def action_set_archive(self):
        for record in self:
            record.state = 'archived' if record.state == 'in_progress' else 'archived_real_estate'
        return {
            'effect': {
                'fadeout': 'slow',
                'message': f"Site archived {self.name}",
                'img_url': '/web/static/src/img/smile.svg',
                'type': 'rainbow_man',
            }
        }

    def action_unset_archive(self):
        for record in self:
            record.state = 'in_progress' if record.state == 'archived' else 'archived'
        return True

Dans la classe d'un modèle :

Vues

Les vues sont définies dans le répertoire views.

<!-- views/surveyor_site_views.xml -->

<?xml version="1.0"?>
<odoo>
    <record id="surveyor_site_action" model="ir.actions.act_window">
        <field name="name">Sites</field>
        <field name="res_model">surveyor.site</field>
        <field name="view_mode">tree,form,kanban</field>
        <field name="context">{'search_default_active_inactive':True}</field>
        <field name="help" type="html">
          <p class="o_view_nocontent_smiling_face">
            Create a new site
          </p><p>
            A site is an address and a cadastral area.
          </p>
        </field>
    </record>

    <record id="surveyor_site_view_search" model="ir.ui.view">
        <field name="name">surveyor.site.search</field>
        <field name="model">surveyor.site</field>
        <field name="arch" type="xml">
            <search string="Search Sites">
                <field name="id"/>
                <field name="name"/>
                <field name="address"/>
                <field name="city"/>
                <field name="city_code"/>
                <filter string="Include inactive" name="active_inactive" domain="['|', ('active', '=', True), ('active', '=', False)]"/>
                <filter string="Inactive" name="inactive" domain="[('active', '=', False)]"/>
                <filter string="Brest" name="city_filter" domain="[('city_code', '=', '29111')]"/>
                <filter string="City" name="city_group" context="{'group_by':'city_code'}"/>
            </search>
        </field>
    </record>

    <record id="surveyor_site_view_form" model="ir.ui.view">
        <field name="name">surveyor.site.form</field>
        <field name="model">surveyor.site</field>
        <field name="arch" type="xml">
            <form string="Terrain">
                <header>
                    <field name="name"/>
                    <field name="state" widget="statusbar" statusbar_visible="in_progress,archived,archived_real_estate"/>
                    <button name="action_set_archive" states="in_progress" string="Archive" type="object"/>
                    <button name="action_unset_archive" states="archived,archived_real_estate" string="Unarchive" type="object"/>
                    <button name="action_set_archive" states="archived" string="Archive Real Estate" type="object" class="oe_highlight"/>
                    <button name="%(surveyor.surveyor_site_action)d" string="Sites" type="action"/>
                </header>
                <sheet>
                    <widget name="web_ribbon" title="Inactive" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
                    <div class="oe_button_box" name="button_box">
                        <button name="%(surveyor.surveyor_site_action)d" class="oe_stat_button" icon="fa-tag" type="action" context="{'search_default_id':id}" attrs="{'invisible': [('tag_count', '=', 0)]}">
                            <field name="tag_count" widget="statinfo" string="Tags"/>
                        </button>
                    </div>
                    <group>
                        <field name="address" attrs="{'readonly': [('state', '!=', 'in_progress')]}"/>
                        <field name="city"/>
                        <field name="city_code"/>
                    </group>
                    <group string="Cadastre">
                        <field name="city_prefix"/>
                        <field name="section_code"/>
                        <field name="plot_code"/>
                    </group>
                    <notebook>
                        <page string="Owner">
                            <group col="3">
                                <group>
                                    <field name="active" widget="boolean_toggle"/>
                                    <field name="user_id" options="{'no_create': True, 'no_open': True}"/>
                                    <field name="partner_id" options="{'no_create': True, 'no_open': True}"/>
                                </group>
                                <group>
                                    <field name="tag_ids" widget="many2many_tags"/>
    <!--                                <field name="tag_ids" context="{'default_category_id': active_id}">-->
    <!--                                    <tree string="Tags" editable="bottom">-->
    <!--                                        <field name="sequence" widget="handle"/>-->
    <!--                                        <field name="name"/>-->
    <!--                                        <field name="color" widget="color_picker"/>-->
    <!--                                    </tree>-->
    <!--                                </field>-->
                                </group>
                            </group>
                        </page>
                    </notebook>
                </sheet>
            </form>
        </field>
    </record>

    <record id="surveyor_site_view_tree" model="ir.ui.view">
        <field name="name">surveyor.site.tree</field>
        <field name="model">surveyor.site</field>
        <field name="arch" type="xml">
            <tree string="Sites" decoration-muted="active==False" decoration-warning="state=='archived'" decoration-danger="state=='archived_real_estate'" >
                <field name="name" optional="show"/>
                <field name="address"/>
                <field name="city"/>
                <field name="city_code"/>
                <field name="city_prefix"/>
                <field name="section_code"/>
                <field name="plot_code"/>

                <field name="user_id" optional="hide"/>
                <field name="partner_id" optional="hide"/>
                <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_edit_color': True}" optional="hide"/>

                <field name="state" optional="show"/>
                <field name="active" optional="hide"/>
            </tree>
        </field>
    </record>

    <record id="surveyor_site_view_kanban" model="ir.ui.view">
        <field name="name">surveyor.site.kanban</field>
        <field name="model">surveyor.site</field>
        <field name="arch" type="xml">
            <kanban create="false" group_create="false">
                <templates>
                    <t t-name="kanban-box">
                        <div t-attf-class="oe_kanban_card oe_kanban_global_click">
                            <progressbar field="state" colors='{"in_progress": "default", "archived": "warning", "archived_real_estate": "danger"}'/>
                            <div class="o_kanban_record_top">
                                <div class="o_kanban_record_headings">
                                    <strong class="o_kanban_record_title"><field name="name"/></strong>
                                </div>
                                <field name="city"/>
                            </div>
                            <div class="o_kanban_record_body">
                                Plot
                                <field name="city_prefix"/>
                                <field name="section_code"/>
                                <field name="plot_code"/>
                            </div>
                            <div class="o_kanban_record_bottom">
                                <div class="oe_kanban_bottom_left text-muted">
                                    <field name="tag_ids" widget="many2many_tags" options="{'color_field': 'color', 'no_edit_color': True}"/>
                                </div>
                                <div class="oe_kanban_bottom_right">
                                    <field name="state" widget="label_selection" options="{'classes': {'in_progress': 'default', 'archived': 'warning', 'archived_real_estate': 'danger'}}"/>
                                </div>
                            </div>
                        </div>
                    </t>
                </templates>
            </kanban>
        </field>
    </record>


    <menuitem id="surveyor_root_menu" name="Surveyor">
        <menuitem id="surveyor_site_menu" name="Sites">
            <menuitem id="surveyor_site_menu_action" action="surveyor_site_action"/>
        </menuitem>
    </menuitem>

    <record id="delete_in_progress_only" model="ir.rule">
        <field name="name">Only in progress sites may be deleted</field>
        <field name="model_id" ref="model_surveyor_site"/>
        <field name="groups" eval="[(4, ref('sales_team.group_sale_manager'))]"/>
        <field name="perm_read" eval="0"/>
        <field name="perm_write" eval="0"/>
        <field name="perm_create" eval="0"/>
        <field name="perm_unlink" eval="1"/>
        <field name="domain_force">[('state','=','in_progress')]</field>
        <!--field name="domain_force">[('create_uid', '=', user.id)]</field-->
    </record>
</odoo>

Dans le fichier des vues :

Rapports et gabarits

Les rapports sont définis dans le répertoire report.

Les rapports sont d'abord des pages Web donc des vues.

Deux fichiers sont nécessaires : model_reports.xml, model_templates.xml qui définissent respectivement la configuration et le contenu du rapport. Le contenu du rapport est défini grâce à des gabarits QWeb.

<!-- views/surveyor_site_reports.xml -->

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <record id="surveyor_site_sites_report" model="ir.actions.report">
        <field name="name">Sites report</field>
        <field name="model">surveyor.site</field>
        <field name="report_type">qweb-pdf</field>
        <field name="report_name">surveyor.report_surveyor_sites</field>
        <field name="report_file">surveyor.report_surveyor_sites</field>
        <field name="print_report_name">'Sites report - %s' % object.name</field>
        <field name="binding_model_id" ref="model_surveyor_site"/>
        <field name="binding_type">report</field>
    </record>
</odoo>


<!-- views/surveyor_site_templates.xml -->
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
    <template id="report_surveyor_sites">
        <t t-call="web.html_container">
            <t t-foreach="docs" t-as="record">
                <t t-call="web.external_layout">
                    <t t-set="address">
                        <address t-field="record.partner_id" t-options='{"widget": "contact", "fields": ["address", "name"], "no_marker": True}' />
                    </t>
                    <t t-set="information_block">
                        <address t-field="user.partner_id" t-options='{"widget": "contact", "fields": ["address", "name"], "no_marker": True}' />
                    </t>
                    <div class="page">
                        <h2><span t-field="record.name"/></h2>
                        <p><span t-field="record.state"/> by <span t-field="record.user_id"/></p>
                        <div>
                            <strong>Address: </strong>
                            <span t-field="record.address"/>
                            <span t-field="record.city"/>
                        </div>
                        <div>
                            <strong>City code: </strong>
                            <span t-field="record.city_code"/>
                        </div>
                        <div>
                            <strong>Cadastral plot: </strong>
                            <span t-field="record.city_prefix"/>
                            <span t-field="record.section_code"/>
                            <span t-field="record.plot_code"/>
                        </div>
                        <table class="table">
                            <thead>
                                <tr>
                                    <th>Tag</th>
                                    <th>Category</th>
                                    <th>Color</th>
                                </tr>
                            </thead>
                            <tbody>
                                <t t-set="tags" t-value="record.mapped('tag_ids')"/>
                                <tr t-foreach="tags" t-as="tag">
                                    <td><span t-field="tag.name"/></td>
                                    <td><span t-field="tag.category_id.name"/></td>
                                    <td><span t-field="tag.color"/></td>
                                </tr>
                            </tbody>
                        </table>
                        <p>Printed on <span t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/></p>
                    </div>
                </t>
            </t>
        </t>
    </template>
</odoo>

Dans les fichiers des rapports :

Dans le template, les éléments docs et user sont disponibles et donnent accès aux enregistrements et à l'utilisateur.

Astuce

Les champs binding_model_id et binding_type permettent d'ajouter automatiquement le rapport dans un menu contextuel "rapport" dans les vues (tree, form...) associées au modèle.

Héritage

L'héritage permet d'étendre les fonctionnalités d'un module existant.

Par convention, le modèle doit porter le même nom que son parent (noms de la classe et du fichier). On utilise l'attribut _inherit :

# models/sale_order.py

from odoo import fields, models

class SaleOrder(models.Model):
    _inherit = "sale.order"

    site_id = fields.Many2one('surveyor.site', string='Site', index=True)

La vue doit porter le même nom que la vue parente en ajoutant .inherit.mymodule. On utilise le champ _inherit_id :

<!-- views/sale_views.xml -->

<?xml version="1.0"?>
<odoo>
    <record id="view_order_form" model="ir.ui.view">
        <field name="name">sale.order.form.inherit.surveyor</field>
        <field name="model">sale.order</field>
        <field name="inherit_id" ref="sale.view_order_form"/>
        <field name="arch" type="xml">
            <group name="order_details" position="after">
                <group name="site_details">
                    <field name="site_id"/>
                </group>
            </group>
        </field>
    </record>
</odoo>

Chargement de données

Des données peuvent être chargées dans la base lors de l'installation du module. Celles-ci peuvent être décrites dans des fichiers CSV ou XML dans le répertoire data. Chaque fichier devant être listé dans le tableau data du manifest. Un jeu de données de démonstration peut aussi être ajouté dans le tableau demo du manifest.

Exemple de jeu de données de démonstration :

<!-- data/surveyor_demo.xml -->

<?xml version="1.0" encoding="utf-8"?>
<odoo>
    <!-- Sites -->
    <record id="site_1" model="surveyor.site">
        <field name="address">11 route de Paris</field>
        <field name="city">Brest</field>
        <field name="city_code">29111</field>
        <field name="city_prefix">111</field>
        <field name="section_code">AI</field>
        <field name="plot_code">222</field>
        <field name="user_id" ref="base.user_admin"/>
        <field name="partner_id" ref="base.partner_demo"/>
        <field name="tag_ids" eval="[(4, ref('event.event_tag_category_2_tag_1')), (4, ref('event.event_tag_category_2_tag_2'))]"/>
        <field name="active">True</field>
        <field name="state">in_progress</field>
    </record>

    <record id="site_2" model="surveyor.site">
        <field name="address">12 route de Paris</field>
        <field name="city">Brest</field>
        <field name="city_code">29111</field>
        <field name="city_prefix">111</field>
        <field name="section_code">AI</field>
        <field name="plot_code">223</field>
        <field name="user_id" ref="base.user_admin"/>
        <field name="partner_id" ref="base.partner_demo"/>
        <field name="tag_ids" eval="[(4, ref('event.event_tag_category_2_tag_1')), (4, ref('event.event_tag_category_2_tag_2'))]"/>
        <field name="active">True</field>
        <field name="state">archived</field>
    </record>
</odoo>

Droits d'accès

Les droits d'accès peuvent être gérés par modèle ou par enregistrement.

Dans le premier cas, un fichier CSV contient les droits par modèle et par utilisateur dans security/ir.model.access.csv. Ce fichier doit être listé dans le tableau data du manifest.

id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_surveyor_site,access_surveyor_site,model_surveyor_site,base.group_user,0,0,0,0
access_surveyor_site_readonly,access_surveyor_site_readonly,model_surveyor_site,sales_team.group_sale_salesman,1,0,0,0
access_surveyor_site_readwrite,access_surveyor_site_readwrite,model_surveyor_site,sales_team.group_sale_manager,1,1,1,1

Dans le second cas, les droits d'accès sont spécifiés dans le fichier de la vue au moyen d'un record ir.rule. On peut alors préciser une condition portant sur les enregistrements.

Internationalisation

La traduction des textes du module est située dans le répertoire i18n qui contient :

Pour compléter et générer ces fichiers il faut activer le mode développeur et aller dans Paramètres > Traductions et Termes traduits puis Exporter une traduction. Exporter le modèle vierge et les fichiers de langues au format PO pour le module concerné.

Un commentaire sur un de mes articles ? Commencez une discussion sur ma liste de diffusion en envoyant un email à ~nora/public-inbox@lists.sr.ht [règles]