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 à :
- Installer une base PostgreSQL
- Installer Odoo depuis les sources
- 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 :
- attribut
_name
: nom du modèle dans le système - attribut
_sql_constraints
: contraintes d'intégrité base de données - attibuts
name
,active
,state
: attributs réservés qui activent des comportements spécifiques. - autres attributs : champs de différents types (colonnes en base, champs de saisie dans les formulaires...)
- attribut
self.env
: accès à la requête (contexte, utilisateur...) - méthode
read
: action à la lecture d'un enregistrement - méthode
create
: action à la création d'un enregistrement - méthode
write
: action à la modification d'un enregistrement - méthode
_ondelete
: action à la suppression d'un enregistrement - méthodes
_onchange_*
: action sur modification d'autres champs - méthodes
_compute_*
: champ calculé à partir d'autres champs - méthodes
_inverse_*
: champ calculé inverse - méthodes
_check_*
: contrainte sur un champ - méthodes
action_*
: actions personnalisées (boutons...)
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 :
menuitem
: entrée dans le menu principalrecord ir.actions.act_window
: action relative au modèle, lien avec les vuesrecord ir.ui.view
: définition des vuesform
: vue formulairesearch
: vue recherchetree
: vue listekanban
: vue kanban
record ir.rule
: droits d'accès par enregistrementfield
: affichage d'un champ du modèlewidget
: affichage alternatif d'un champ du modèle
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 :
ir.actions.report
: action de génération du rapporttemplate
: définition du gabaritt
,t-*
: balises et attributs QWeb
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
etbinding_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 :
module.pot
: textes de référencefr.po
: traductions françaises*.po
: traductions autres langues
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é.