Odoo
Sumario
- 1 El servidor Odoo
- 2 Arquitectura
- 3 La base de dades d'Odoo
- 4 Els mòduls
- 5 ORM
- 6 Fitxers de dades
- 7 Accions i menús
- 8 La vista
- 9 Els reports
- 10 Herència
- 11 El controlador
- 12 Wizards
- 13 Client web
- 14 Web Controllers
- 15 Pàgina web
- 16 Exemples
- 17 Misc.
- 18 Enllaços
El servidor Odoo
Arquitectura
El framework d'Odoo facilita diversos components que permeten construir l’aplicació:
- La capa ORM (Object Relational Mapping) entre els objectes Python i la base de dades PostgreSQL. El dissenyador-programador no efectua el disseny de la base de dades; únicament dissenya classes, per les quals la capa ORM d’Odoo n’efectuarà el mapat sobre el SGBD PostgreSQL.
- Una arquitectura MVC (model-vista-controlador) en la qual el model resideix en les dades de les classes dissenyades amb Python, la vista resideix en els formularis, llistes, calendaris, gràfics… definits en fitxers XML i el controlador resideix en els mètodes definits en les classes que proporcionen la lògica de negoci.
- Odoo és un ERP amb una arquitectura de Tenencia Múltiple. És A dir, té una base de dades i un servidor comú per a tots els clients. El contrari sería tindre un servidor o base de dades per client o virtualitzar.
- Dissenyadors d’informes.
- Facilitats de traducció de l’aplicació a diversos idiomes.
El servidor Odoo proporciona un accés a la base de dades emb ORM. El servidor necessita tindre instal·lats mòduls, ja que comença buit.
Per altra banda, el client es comunica amb el servidor en XML-RPC, els clients web per JSON-RPC. El client sols té que mostrar el que dona el servidor o demanar correctament les dades. Per tant, un client pot ser molt simple i fer-se en qualsevol llenguatge de programació. Odoo proporciona un client web encara que es pot fer un client en qualsevol plataforma.
Les dades estan guardades en una base de dades relacional. Gràcies a l'ORM, no cal fer consultes SQL directament. ORM proporciona una serie de mètodes per a treballar de manera més ràpida i segura. En compte de parlar de taules es parla de models. Aquest són 'mapejats' per l'ORM en taules. No obstant, un model té més que dades en una taula. Un model es comporta con un objecte al tindre camps funcionals, restriccions i camps relacionals que deixen la normalització de la base de dades en mans d'Odoo.
L'accés del client a les dades es fa fent ús d'un servici. Aquest pot ser WSGI. WSGI és una solució estàndard per a fer servidors i clients HTTP en Python. En el cas d'Odoo, aquest té el OpenERP Web Project, que és el servidor web.
Un altre concepte dins d'Odoo són els Business Objects quasi tot en Odoo és un Business Object, és persistent gràcies a ORM i es troba estructurat en el directori /modules.
Odoo proporciona els anomenats Wizards, que es comporten com un assistent per introduir dades d'una manera més fàcil per a l'usuari.
El client web és fàcil de desenvolupar gràcies al Widgets o Window GaDGETS. Aquests proporcionen un comportament i visualització correctes per a cada tipus de dades. Per exemple: si el camp és per definir una data, mostrarà un calendari. Alguns tenen diferents visualitzacions en funció del tipus de vista i se'n poden definir Widgets personalitzats.
Tal com es pot observar, són molts els components d’OpenObject a conèixer per poder adequar l’Odoo a les necessitats de l’organització, en cas que les funcionalitats que aporta l’Odoo, tot i ser moltes, no siguin suficients.
La base de dades d'Odoo
En l’Odoo no hi ha un disseny explícit de la base de dades, sinó que la base de dades d’una empresa d’Odoo és el resultat del mapatge del disseny de classes de l’ERP cap el SGBD PostgreSQL que és el que proporciona la persistència necessària per als objectes. Això és el ORM.
En conseqüència, l’Odoo no facilita cap disseny entitat-relació sobre la base de dades d’una empresa ni tampoc cap diagrama del model relacional.
Si sorgeix la necessitat de detectar la taula o les taules on resideix determinada informació, és perquè es coneix l’existència d’aquesta informació gestionada des de l’ERP i, per tant, es coneix algun formulari de l’ERP a través del qual s’introdueix la informació.
L’Odoo possibilita, mitjançant el clients web recuperar el nom de la classe Python que defineix la informació que s’introdueix a través d’un formulari i el nom de la dada membre de la classe corresponent a cada camp del formulari. Aquesta informació permet arribar a la taula i columna afectades, tenint en compte dues qüestions:
- El nom de les classes Python d’Odoo sempre són en minúscula (s’utilitza el guió baix per fer llegible els mots compostos) i segueix la nomenclatura nom_del_modul.nom1.nom2.nom3… en la qual s’utilitza el punt per indicar un cert nivell de jerarquia. Cada classe Python d’Odoo és mapada en una taula de PostgreSQL amb moltes possibilitats que el seu nom coincideixi amb el nom de la classe, tot substituint els punts per guions baixos.
- Els noms dels atributs d’una classe Python sempre són en minúscula (s’utilitza el guió baix per fer llegible els mots compostos). Cada dada membre d’una classe Python d’Odoo que sigui persistent (una classe pot tenir dades membres calculades no persistents) és mapat com un atribut en la corresponent taula de PostgreSQL amb el mateix nom.
D’aquesta manera, coneixent el nom de la classe i el nom de la dada membre, és molt possible conèixer el nom de la taula i de la columna corresponents. Es pot configurar el client web per tal que informe del nom de la classe i de la dada membre en situar el ratolí damunt les etiquetes dels camps dels formularis.
Els mòduls
Tant el servidor com els clients són mòduls. Tots estàn guardats en una base de dades. Tot els que es puga fer per modificar Odoo es fa en mòduls.
Composició d'un mòdul
Els mòduls d'Odoo amplien o modifiquen parts de Model-Vista-Controlador. D'aquesta manera, un mòdul pot tindre:
- Objectes de negoci: Són la part del model, estan definits en classes de Python segons una sintaxy pròpia de l'ORM d'Odoo.
- Fitxers de dades: Són fitxers XML que poden definir dades, vistes o configuracions.
- Controladors web: Gestionen les peticions dels navegadors web.
- Dades estàtiques: Imatges, CSS, o javascript utilitzats per l'interficie web.
Estructura de fitxers d'un mòdul
- Tots el mòduls estan en un directori definit en l'opció --addons-path o el fitxer de configuració. Poden ser més d'un directori.
- Un mòdul de python es declara en un fitxer de manifest que dona informació sobre el mòdul, el que fa el mòduls dels que depen i cóm s'ha d'instal·lar o actualitzar. [1]
- Un mòdul és un paquet de Python que necessita un __init__.py per a instanciar tots els fitxers python.
Creació de mòduls
Per ajudar al programador, Odoo conté un comandament per crear mòduls buits. Aquest crea l'estructura de fitxers necessaria per començar a treballar:
$ odoo scaffold <module name> <where to put it>
Més al voltant d'Scaffold:
ORM
Odoo mapeja els seus objectes en una base de dades amb ORM, evitant al programador la majoria de consultes SQL. D'aquesta manera el desenvolupament dels mòduls és molt ràpid i evitem errades de programació.
Els models són creats com classes de python que extenen la classe models.Model que conté els camps i mètodes útils per a fer anar l'ORM.
Els models, al heretar de models.Model, necessiten donar valor a algunes variables, com ara _name
Odoo considera que un model és la referència a una o més taules en la base de dades. Un model no és una fila en la taula, és tota la taula.
Els models en Odoo poden heretar de models.Model i ser els normals mapejats i permanents en la base de dades. També poden ser models.TransientModel i són iguals, sols que no tenen permanència definitiva en la base de dades. Aquest són els recomanables per a fer wizards. També poden ser models.AbstractModel per a definir models abstractes per a després heretar.
Inspeccionar el models
Per veure els models existents, es pot accedir a la base de dades postgreSQL o mirar en Configuración > Estructura de la base de datos > Modelos dins del mode desenvolupador.
Cal destacar el camp modules on diu els mòduls instal·lats on es defineix o hereta el model observat.
Fields
Les "columnes" del model són els fields. Aquests poden ser de dades normals com Integer, Float, Boolean, date, char... o especials como many2one, one2many, related...
Hi ha uns fields reservats:
- id (Id) the unique identifier for a record in its model
- create_date (Datetime) creation date of the record
- create_uid (Many2one) user who created the record
- write_date (Datetime) last modification date of the record
- write_uid (Many2one) user who last modified the record
Hi ha altres fields que podem declarar i tenen propietats especials. Aquests són els més importants:
- name És el field utiltizat per fer l'Identificador Extern o quan es fa referència en els many2one en la vista.
- active que diu si el record és actiu. Permet ocultar productes que ja no es necessiten, per exemple.
- sequence Permet definir l'ordre del records a mostrar en una llista.
- state És de tipus selection i permet crear un cicle de vida d'un model. Amés es pot representar en el <head> d'un form amb un widget statusbar i els fields de les vistes poden ocultar-se en funció d'un camp state ficant l'atribut states="".
Els fields es declaren amb un constructor:
from openerp import models, fields
class LessMinimalModel(models.Model):
_name = 'test.model2'
name = fields.Char()
Tenen uns atributs comuns:
- string (unicode, per defecte: El nom del field) L'etiqueta que veuran els usuaris en la vista.
- required (bool, per defecte: False) Si és True, el camp no es por deixar buit.
- help (unicode, per defecte: ) En els formularis proporciona ajuda a l'usuari per plenar el camp.
- index (bool, per defecte: False) Demana a Odoo fer que siga el índex de la base de dades. En altre cas, el ORM crea un camp id.
I algunes, sobretot les especials, tenen atributs particulars.
Exemple complet:
class AModel(models.Model):
_name = 'a_name'
name = fields.Char(
string="Name", # Optional label of the field
compute="_compute_name_custom", # Transform the fields in computed fields
store=True, # If computed it will store the result
select=True, # Force index on field
readonly=True, # Field will be readonly in views
inverse="_write_name" # On update trigger
required=True, # Mandatory field
translate=True, # Translation enable
help='blabla', # Help tooltip text
company_dependent=True, # Transform columns to ir.property
search='_search_function', # Custom search function mainly used with compute
copy =True # Si es pot copiar amb el mètode copy()
)
# The string key is not mandatory
# by default it wil use the property name Capitalized
name = fields.Char() # Valid definition
Si volem valors per defecte, es poden indicar com un atribut del field.
name = fields.Char(default='Alberto')
# o:
name = fields.Char(default=a_fun)
#...
def a_fun(self):
return self.do_something()
Veure: Valors per defecte
Fields normals
Aquests són els fields per a dades normals que proporciona Odoo:
- Integer
- Char
- Text
- Date : Mostra un calendari en la vista.
- Datetime
- Float
- Boolean
- Html : Guarda un text, però es representa de manera especial en el client.
- Binary : Per guardar, per exemple, imatges. Utilitza codificació base64.
- Image (Odoo13) : En el cas d'imatges, accepta els atributs max_width i max_height on es pot dir en píxel que ha de redimensionar la imatge a eixa mida màxima.
- Selection : Mostra un select amb les opcions indicades.
type = fields.Selection([('1','Basic'),('2','Intermediate'),('3','Completed')])
aselection = fields.Selection(selection='a_function_name') # Es pot cridar a una funció que defineix les opcions.
Fields Relacionals
Les relacions entre els models (en definitiva, entre les taules de la base de dades) també les simplifica l'ORM. D'aquesta maneram les relacions 1 a molts es fan en el Odoo anomena Many2one i les relacions Mols a Molts es fan el el Many2Many. Les relacions molts a molts, en una base de dades relacional, impliquen una tercera taula en mitg, però en Odoo no tenim que preocupar-nos d'aquestes coses si no volem, el mapat dels objectes el detectarà i farà les taules, claus i restriccions d'integritat necessaries. Anem a repasar un a un aquests camps:
Reference
Una referència arbitrària a un model i un camp. [2]
aref = fields.Reference([('model_name', 'String')])
aref = fields.Reference(selection=[('model_name', 'String')])
aref = fields.Reference(selection='a_function_name')
# Fragment de test_new_api:
reference = fields.Reference(string='Related Document', selection='_reference_models')
@api.model
def _reference_models(self):
models = self.env['ir.model'].search([('state', '!=', 'manual')])
return [(model.model, model.name)
for model in models
if not model.model.startswith('ir.')]
Els fields reference no són molt utilitzats, ja que normalment les relacions entre models són sempre les mateixes.
Many2one
Relació amb un altre model
arel_id = fields.Many2one('res.users')
arel_id = fields.Many2one(comodel_name='res.users')
an_other_rel_id = fields.Many2one(comodel_name='res.partner', delegate=True)
En aquest cas:
---------- ----------- | Pais | one | Ciutat | ---------- ----- ----------- | * id | | | * id | | * name | | many| * name | ---------- |------| * pais | -----------
El codi resultant sería:
class ciutat(models.Model):
_name = 'mon.ciutat'
pais = fields.Many2one("mon.pais", string='Pais', ondelete='restrict')
delegate està en True per a fer que els fields del model apuntat siguen accessibles des del model actual.
Accepta també context i domain com en la vista. D'aquesta manera queda disponible per a totes les possibles vistes.
Un altre argument addicional és ondelete que permet definir el comportament al esborrar l'element referenciat a set null, restrict o cascade.
One2many
Inversa del Many2one. Necessita de la existència d'un Many2one en l'altre:
arel_ids = fields.One2many('res.users', 'arel_id')
arel_ids = fields.One2many(comodel_name='res.users', inverse_name='arel_id')
Un One2many funciona perquè hi ha un many2one en l'altre model. D'aquesta manera, sempre has de especificar el nom del model i el nom del camp Many2one del model que fa referencia a l'actual, com es pot veure en l'exemple.
En l'exemple anterior, quedaria com:
class pais(models.Model):
_name = 'mon.pais'
ciutats = fields.One2many('mon.ciutat', 'pais', string='Ciutats')
Many2many
Relació molts a molts.
arel_ids = fields.Many2many('res.users')
arel_ids = fields.Many2many(comodel_name='res.users', # El model en el que es relaciona
relation='table_name', # (opcional) el nom del la taula en mig
column1='col_name', # (opcional) el nom en la taula en mig de la columna d'aquest model
column2='other_col_name') # (opcional) el nom de la columna de l'altre model.
El primer exemple sol funcionar directament, però si volem tindre més d'una relació Many2many entre els dos mateixos models, cal utilitzar la sintaxi completa on especifiquem el nom de la relació i el nom de les columnes que identifiquem els dos models. Pensem que una relació Many2many implica una taula en mig i estem especificant les seues claus alienes.
Related
Un camp d'un altre model, necessita una relació Many2one. Encara que estiga Store=True, Odoo 8.0 l'actualitza correctament. D'aquesta manera es poden aprofitar les funcionalitats de guardar, com ara les búsquedes o referències en funcions. En termes de bases de dades, un camp related trenca la tercera forma normal. Això sol ser problemàtic, però Odoo té mecanismes per a que no passe res. De totes maneres, si ens preocupa això, amb store=False no guarda res en la taula.
participant_nick = fields.Char(string='Nick name',
store=True,
related='partner_id.name'
Un camp related pot ser de qualsevol tipus. Per exemple, many2one:
sala = fields.Many2one('cine.sala',related='sessio.sala',store=True,readonly=True)
Many2oneReference
Un Many2one on es guardar també el model al qual fa referència amb el atribut: model_field.
One2one
Els camps One2one no existeixen en Odoo. Però si volem aquesta funcionalitat podem utilitzar varies tècniques:
- Fer dos camps Many2many i restringir amb constrains que sols pot existir una relació. Problemes:
- En la vista no podem ficar un widget com en el Many2one i és complicat evitar relacions creuades.
- Es pot fer un limit en la vista, però es continuarà comportant com un Many2many.
- Fer dos Many2one i restringit amb contrains o sql constrains que sols pot existir una relació mútua. (Cal sobreescriure els mètodes create i write per a que es cree l'associació automàticament). Problemes:
- Si sobreescribim el write de els dos, es pot produir una cridada recursiva sense fi i és molt complicat aconseguir que no tingam referències creuades.
- Fer un Many2one i en l'altre model un Many2one computed que busque en els del primer model. Per poder editar en els dos cal fer una funció inversa per al camp computed. Aquesta és una de les opcions més elegants. Exemple:
- Fer un Many2one i un One2many i restringir el màxim del One2many ( [3] ). Problemes:
- Els mateixos que en els dos many2manys. És més simple restringir les relacions creuades.
- Fer una herència múltiple. [4]. Problemes:
- Esta és, en teoría, la forma més oficial de fer-ho, però obliga a crear sempre la relació i els models en un ordre determinat.
Filtres (Domains)
En ocasions és necessari afegir un filtre en el codi python per fer que un camp relacional no puga tindre certes referències. El comportament del domain és diferent depen del tipus de field.
- Domain en Many2one: Filtra els elements del model referenciat que poden ser elegits per al field:
parent = fields.Many2one('game.resource', domain="[('template', '=', True)]")
- Domain en Many2many: La llista d'elements a triar es filtra segons el domain:
characters_attack = fields.Many2many('game.character',
relation='characters_attack',
domain="[('id', 'in', characters_attack_available)]")
- Domain en One2many: Al ser una relació que depen d'altre Many2one, no es pot filtrar, si fiquem un domain, sols deixarà de mostrar els que no compleixen el domain, però no deien d'existir:
raws = fields.One2many('game.raws','clan', domain= lambda s: [('quantity','>',0)])
Observem com hem fet un domain amb lambda, és a dir, aquest domain crida a una funció lambda al ser aplicat.
Fields Computed
Moltes vegades volem que el contingut d'un camp siga calculat en el moment en que anem a veure-lo. Tots els tipus de fields poden ser computed. Anem a veure alguns exemples:
taken_seats = fields.Float(string="Taken seats", compute='_taken_seats') # Aquest camp no es guarda a la base de dades
#i sempre es recalcula quan executem un action que el mostra
@api.depends('seats', 'attendee_ids') # El decorador @api.depends() indica que es cridarà a la funció
# sempre que es modifiquen els camps seats i attendee_ids.
#Si no el posem, es recalcula sols al recarregar el action.
def _taken_seats(self):
for r in self: # El for recorre self, que és un recordset amb tots els elements del model mostrats
# per la vista, si és un tree, seran tots els visibles i si és un form, serà un singleton.
if not r.seats: # r és un singleton i es pot accedir als fields com a variables de l'objecte.
r.taken_seats = 0.0
else:
r.taken_seats = 100.0 * len(r.attendee_ids) / r.seats
En aquest exemple es veu cóm el camp float taken seats es calcula en una funció privada _taken_seats. És interessant observar el for perquè recorre totes les instancies a les que fa referència el model. Aquesta funció sols s'executarà una vegada encara que tinga que calcular tots els elements d'una llista. Per això, la propia funció és la que té que iterar els elements de self. self és un recordset, per tant, és com una llista en la que cada element és un registre del model. Si el computed és cridat al entrar a un formulari, el recordset tindrà sols un element, però si el camp computed es veu en una llista (tree), pot ser que siguen més d'un registre. És important recordar fer el for record in self: encara que pensem que el camp computed sols l'utilitzarem en un formulari.
Un altre exemple:
class ComputedModel(models.Model):
_name = 'test.computed'
name = fields.Char(compute='_compute_name')
value = fields.Integer()
@api.depends('value')
def _compute_name(self):
for record in self:
self.name = "Record with value %s" % self.value
En aquest exemple, és el nom el que és calcular a partir del value.
Exemples de computed de tots els tipus de fields:
En l'apartat del controlador s'expliquem més detalls de les funcions en python-odoo.
Buscar i escriure en camps computed
Amb el api.depends podem fer que camps calculats puguen ser buscats o referenciats des d'uns altres models, ja que podem dir que sí se guarden en la base de dades. Si es guarda en la base de dades no es recalcula fins que no canvia el contingut del field del que depèn. Però si el camp calculat no depèn de valors estàtics d'altres fields i/o necessitem que sempre es calcule, no tenim moltes opcions elegants. Una d'elles pot ser fer dos camps, un calculat store=False i altre no i fer un write en la funció. L'altra possibilitat és fer una funció pública que puga ser cridada des d'un altre model. La més elegant que no sempre funciona és utilitzar l'opció search i assignar-li una funció que ha de retornar un domini de búsqueda. El problema és que no accepta molta complexitat, ja que suposa una cerca per tota la base de dades i pot ser molt ineficient.
Per defecte no es pot escriure en un camp computed. No té massa sentit la majoria dels casos, ja que és un camp que depèn d'altres. Però pot ser que, de vegades volem escriure el resultat i que modifique el camp origen. Imaginem, per exemple, que sabem el preu final i volem que calcule el preu sense IVA. Per fer-ho, la millor manera és crear una funció i fer que estiga en l'opció inverse.
Exemple:
Documentació oficial: https://www.odoo.com/documentation/12.0/reference/orm.html#computed-fields
Valors per defecte
En Odoo és molt fàcil fer valors per defecte, ja que és un argument més en el constructor dels fields:
name = fields.Char(default="Unknown")
user_id = fields.Many2one('res.users', default=lambda self: self.env.user)
start_date = fields.Date(default=fields.Date.today())
active = fields.Boolean(default=True)
def compute_default_value(self):
return self.get_value()
a_field = fields.Char(default=compute_default_value)
Si volem, per exemple, ficar la data del moment de crear, no podem fer això:
start_date = fields.Date(default=fields.Date.today()) # MAL
Perquè calcula la data del moment d'actualitzar el mòdul, no el de crear l'element en el model. Cal fer:
start_date = fields.Date(default=lambda self: fields.Date.today()) # CORRECTE
o
start_date = fields.Datetime(default=lambda self: fields.Datetime.now()) # CORRECTE
El valor per defecte no pot dependre d'un field que està creant-se en eixe moment. En eixe cas es pot utilitzar un on_change.
Restriccions (constrains)
Els objectes poden incorporar, de forma opcional, restriccions d’integritat, addicionals a les de la base de dades. Odoo valida aquestes restriccions en les modificacions de dades i, en cas de violació, mostra una pantalla d’error.
from openerp.exceptions import ValidationError
@api.constrains('age')
def _check_something(self):
for record in self:
if record.age > 20:
raise ValidationError("Your record is too old: %s" % record.age)
# all records passed the test, don't return anything
En ocasions, quan tenim clar cóm faríem aquesta restricció en SQL, tal vegada ens resulte més interessant fer una restricció de la base de dades amb una sql constraint. Aquestes es defineixen amb 3 strings (name, sql_definition, message). Per exemple:
_sql_constraints = [
('name_uniq', 'unique(name)', 'Custom Warning Message'),
('contact_uniq', 'unique(contact)', 'Custom Warning Message')
]
En aquest cas és una restricció d'unicitat, la qual és més simple que fer una búsqueda en python.
Fitxers de dades
Quan fem un mòdul d'Odoo, es poden definir dades que es guardaran en la base de dades. Aquestes dades poden ser necessàries per al funcionament del mòdul, de demostració o inclús part de la vista.
Tots els fitxers de dades són en XML i tenen una estructura com esta:
<odoo>
<record model="{model name}" id="{record identifier}">
<field name="{a field name}">{a value}</field>
</record>
<odoo>
Dins de les etiquetes odoo (o les etiquetes openerp i data en versions anteriors) poden trobar una etiqueta record per cada fila en la taula que volem introduir. Cal especificar el model i el id. El id és un identificador extern, que no te perquè coincidir amb la clau primària que l'ORM utilitzarà després. Cada field tindrà un nom i un valor.
External Ids
Tots els records de la base de dades tenen un identificador únic en la seua taula, el id. És un número auto incremental assignat per la base de dades. No obstant, si volem fer referència a ell en fitxers de dades o altres llocs, no sempre tenim perquè saber el id. La solució d'odoo són els External Identifiers. Això és una taula que relaciona cada id de cada taula en un nom. Es tracta del model ir.model.data. Per trobar-los cal anar a:
Settings > Technical > Sequences & identifiers > External Indentifiers
Ahí dins trobem la columna Complete ID.
Per trobar les id al fer fitxers de demostració o de dades podem anar al menú, però eixes ids canvien d'una instal·lació a un altra. Per tant, cal utilitzar les external id. Per aconseguir-lo podem obrir el mode desenvolupador i obrir el menú View Metadata.
En les dades de demo, els external Ids s'utilitzen per no utilitzar les id, que poden canviar al ser auto incrementals. Per a que funcione cal utilitzar l'atribut ref:
<field name="product_id" ref="product.product1"/>
Veure també la funció ref() de l'ORM
Expressions
De vegades volem que els fields es calculen cada vegada que s'actualitza el mòdul. Això es pot fer en l'atribut eval que avalua una expressió de Python.
<field name="date" eval="(datetime.now()+timedelta(-1)).strftime('%Y-%m-%d')"/>
<field name="product_id" eval="ref('product.product1')"/> # Equivalent a l'exemple anterior
<field name="price" eval="ref('product.product1').price"/>
Per al x2many, es pot utilitzar el eval per assignar una llista d'elements.
<field name="tag_ids" eval="[(6,0,[ref('vehicle_tag_leasing'),ref('fleet.vehicle_tag_compact'),
ref('fleet.vehicle_tag_senior')] )]" />
Observem que hem passat una tripleta amb un 6, un 0 i una llista de refs. Les tripletes poden ser:
- (0,_ ,{'field': value}): Crea un nou registre i el vincula
- (1,id,{'field': value}): Actualtiza els valors en un registre ja vinculats
- (2,id,_): Desvincula i elimina el registre
- (3,id,_): Desvincula però no elimina el registre de la relació.
- (4,id,_): Vincula un registre ja existent
- (5,_,_): Desvincula pero no elimina tots els registres vinculats
- (6,_,[ids]): Reemplaça la llista de registres vinculats.
Esborrar
Amb l'etiqueta delete es pot especificar els elements a esborrar amb el external ID o amb un search:
<delete model="cine.sessio" id="sessio_cine1_1"></delete>
Generar dades de demo
Es poden crear fitxers de dades de demo amb qualsevol llenguatge de programació si necessitem moltes dades aleatòries. Per exemple:
Accions i menús
Diagrama de cóm es comporta el client web quan carrega Odoo per primera vegada i cóm crida a un action i carrega les vistes i les dades (records) +----------------------+ +----------------------+ | | GET / al port 8069 | | | Navegador Web +--------------------------> | Servidor Odoo | | | | | +----------------------+ index.html (bàsic) | | | Enllaços a JS i CSS <----------------------------+ | +----------------------+ | | | | GET JS i CSS Qweb +----------------------+ | +----------------------------> Crea els Assets | | | +----------------------+ +----------------------+ CSS i JS ASSETS Templates | | | Inicia Client Web <----------------------------+ | +----------------------+ | | | | POST Load Views +----------------------+ | +----------------------------> ir.ui.view | | | arch i json amb els fields | | | +<---------------------------+ | | | +----------------------+ +----------------------+ POST load action +----------------------+ | Pulsem un menú +----------------------------> ir.ui.action | +----------------------+ Definició de l'action | | | <----------------------------+ | | l'Action necessita | +----------------------+ | vistes | POST Load Views | | | +----------------------------> ir.ui.view | | | Totes les vistes i fields | | | El client analitza <----------------------------+ | | quins field necessita| POST Search read +----------------------+ +---------------------------------------------------> Selecciona i computa | | | Json amb els records | el fields | |El client renderitza <---------------------------------------------------+ |la vista amb els | | | |records | | | +----------------------+ +----------------------+
El client web de Odoo conté uns menús dalt i a l'esquerra. Aquests menús, al ser accionats mostren altres menús i les pantalles del programa. Quant pulsem en un menú, canvia la pantalla perquè hem fet una acció.
Una acció bàsicament té:
- type: El tipus d'acció que és i cóm l'acció és interpretada. Quan la definim en el XML, el type no cal especificar-lo, ja que ho indica el model en que es guarda.
- name: El nom, que pot ser mostrat en la pantalla o no. Es recomana que siga llegible per els humans.
Les accions i els menús es declaren en fitxers de dades en XML o dirèctament si una funció retorna un diccionari que la defineix. Les accions poden ser cridades de tres maneres:
- Fent clic en un menú.
- Fent clic en botons de les vistes (han d'estar connectats amb accions).
- Com accions contextuals en els objectes.
D'aquesta manera, el client web pot saber quina acció ha d'executar si rep alguna d'aquestes coses:
- false: Indica que s'ha de tancar el diàleg actual.
- Una string: Amb l'etiqueta de l'acció de client a executar.
- Un número: Amb el ID o external ID de l'acció a trobar a la base de dades.
- Un diccionari: Amb la definició de l'acció, aquesta no està ni en XML ni en la base de dades. En general, és la manera de cridar a un action al finalitzar una funció.
Accions tipus window
Les accions window són un record més (ir.actions.act_window). No obstant, els menús que les criden, tenen una manera més ràpida de ser declarats amb una etiqueta menuitem:
<record model="ir.actions.act_window" id="action_list_ideas">
<field name="name">Ideas</field>
<field name="res_model">idea.idea</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem id="menu_ideas" parent="menu_root" name="Ideas" sequence="10"
action="action_list_ideas"/>
Exemple:
Sols el tercer nivell de menús pot tindre associada un action. El primer és el menú de dalt i el segon no es 'clicable'.
Aquest exemple és una funció cridada per un botó que retorna un action:
Anem a veure en detall tots els fields que tenen aquestes accions:
- res_model: El model del que mostrarà les vistes.
- views: Una llista de parelles en el ID de la vista i el tipus. En cas de que no sabem el ID de la vista, podem ficar false i triarà o crearà una per defecte. Observem l'exemple anterior, on en la declaració de l'acció no s'especifica aquest field, però el client si acaba rebent-lo amb "views":false,"form". La llista de vistes la trau automàticament amb la funció fields_view_get().
- res_id: (Opcional) Si es va a mostrar un form, indica la ID del record que es va a mostrar.
- search_view_id: (Opcional) Se li pasa (id, name) on id respresenta el ID de la vista search que es mostrarà.
- target: (Opcional) El destí del action. Per defecte és en la finestra actual (current), encara que pot ser a tota la pantalla (full_screen) o en un diàleg o pop-up (new) o main en cas de voler que es veja en la finestra actual sense les breadcrumbs, el que vol dir que elimina el rastre d'on vé l'acció.
- context: (Opcional)Informació addicional. (veure context)
- domain: (Opcional)(veure Domains)
- limit: (Opcional) Per defecte 80, és la quantitat de records que mostrar en la vista tree.
- auto-search: (Opcional) En cas de que necessitem una búsqueda només carregar la vista.
Exemples d'Actions declarades en python:
# Action per obrir arbre i form:
{
"type": "ir.actions.act_window",
"res_model": "res.partner",
"views": [[False, "tree"], [False, "form"]],
"domain": [["customer", "=", true]],
}
# Action sols per a form en un id específic.
{
"type": "ir.actions.act_window",
"res_model": "product.product",
"views": [[False, "form"]],
"res_id": a_product_id,
"target": "new",
}
Quan guardem una action en la base de dades, normalment definint-la com un XML, tenim aquest altres fields:
- view_mode: Lista separada per comes de les vistes que ha de mostrar. Una vegada el servidor va a enviar aquest action al client, amb açò generarà el field views.
- view_ids: Una llista d'objectes de vista que permet definir la vista de la primera part de views. Aquesta llista és un Many2many amb les vistes i la taula intermitja es diu ir.actions.act_window.view.
- view_id: Una vista específica a afegir a views.
Per tant, si volem definir les vistes que volem que mostre el action, podem omplir els camps anteriors. El servidor observa la llista de view_ids i afegeix el view_id. Si no ompli tot el definit en view_mode, acaba d'omplir el field views (el que envía als clients) amb (False,<tipus>). Exemple de cóm especificar una vista en un action:
<field name="view_ids" eval="[(5, 0, 0),(0, 0, {'view_mode': 'tree', 'view_id': ref('tree_external_id')}),(0, 0, {'view_mode': 'form', 'view_id': ref('form_external_id')}),]" />
Com es pot veure en la secció d'expressions en els fitxers de dades, aquesta sintaxi és per a modificar fields Many2many. El (5,0,0) per a desvincular les possibles vistes. El (0,0,<record>) per crear un nou record i vincular-ho. En aquest cas, crea un record amb els dos fields necessaris, el tipus de vista i el External ID de la vista a vincular.
Això també es pot fer insertant records en ir.actions.act_window.view, mireu la secció d'herencia en la vista per veure un exemple.
Accions tipus URL
Aquestes accions símplement obrin un URL. Exemple:
{
"type": "ir.actions.act_url",
"url": "http://odoo.com",
"target": "self", # Target pot ser self o new per reemplaçar el contingut de la pestanya del navegador o obrir una nova.
}
Accions tipus Server
Les accions tipus server funcionen en un model base i poden ser executades automàticament o amb el menú contextual d'acció que es veu dalt en la vista.
Les accions que pot fer un server action són:
- Executar un codi python. Amb un bloc de codi que serà executat al servidor.
- Crear un nou record.
- Escriure en un record existent.
- Executar varies accions. Per poder executar varies accions server.
Com es pot veure al codi de les server action:
state = fields.Selection([
('code', 'Execute Python Code'),
('object_create', 'Create a new Record'),
('object_write', 'Update the Record'),
('multi', 'Execute several actions')], string='Action To Do',
default='object_write', required=True,
help="Type of server action. The following values are available:\n"
"- 'Execute Python Code': a block of python code that will be executed\n"
"- 'Create': create a new record with new values\n"
"- 'Update a Record': update the values of a record\n"
"- 'Execute several actions': define an action that triggers several other
server actions\n"
"- 'Send Email': automatically send an email (Discuss)\n"
"- 'Add Followers': add followers to a record (Discuss)\n"
"- 'Create Next Activity': create an activity (Discuss)")
Permet executar codi en el servidor. És una acció molt genèrica que pot, inclús retornar una acció tipus window. Les accions tipus server són una forma més genèrica del que fa el button tipus object.
Vejem un exemple:
<record model="ir.actions.server" id="print_instance">
<field name="name">Res Partner Server Action</field>
<field name="model_id" ref="model_res_partner"/>
<field name="state">code</field>
<field name="code">
raise Warning(model._name)
</field>
</record>
En l'exemple anterior podem veure les característiques bàsiques:
- ir.action.server: El nom del model on es guardarà.
- model_id: És l'equivalent a res_model en les accions tipus window. Es tracta del model sobre el que treballarà l'action.
- code: Troç de codi que executarà. Pot ser un python complex o el nom d'un mètode que ja tinga el model.
El servidor rebrà del client la ordre d'executar eixe action. Eixa ordre és un Json en el que sols es diu la action_id del action i el context. Dins del context, tenim coses com els active_id, active_ids o el active_model. El servidor executa sobre eixe model el codi que diu l'action. En l'exemple anterior, simplement diu una alerta.
El codi del action server pot definir una variable anomenada action que retornarà al client la seguent acció a executar. Aquesta pot ser window, això pot servir per refescar la pàgina o enviar a una altra. Exemple:
<record model="ir.actions.server" id="print_instance">
<field name="name">Res Partner Server Action</field>
<field name="model_id" ref="model_res_partner"/>
<field name="state">code</field>
<field name="code">
if object.some_condition():
action = {
"type": "ir.actions.act_window",
"view_mode": "form",
"res_model": model._name,
"res_id": object.id,
}
</field>
</record>
Però no sempre s'utilitza l'etiqueta code. Això depen d'una altra anomenada state que pot tindre el tipus d'acció de servidor. Estan disponibles els següents valors:
- code : Executar codi Python': un bloc de codi Python que serà executat. En el cas d'utilitzar code, el codi té accés a algunes variables específiques:
- env: Enviroment d'Odoo en el que l'action s'executa.
- model: Model en que s'executa. Es tracta d'un recordset buit.
- record: El registre en que s'executa l'acció.
- records: Recordset de tots els registres en que s'executa l'acció (si es cridada per un tree, per exemple)
- time, datetime, dateutil, timezone Bilioteques Python útils (són python pures, no d'odoo)
- log(message, level='info'): Per enviar missatges al log.
- Warning per llançar una excepció amb raise.
- action={...} per llançar una acció.
- object_create: Crear o duplicar un nou registre: crea un nou registre amb nous valors, o duplica un d'existent a la base de dades
- object_write: Escriure en un registre: actualitza els valors d'un registre
- multi: Executar diverses accions: defineix una acció que llança altres diverses accions de servidor
- followers: Afegir seguidors: afegeix seguidors a un registre (disponible a Discuss)
- email: Enviar un correu electrònic: envia automàticament un correu electrònic (disponible a email_template)
Exemple complet de action tipus server. (No fa res útil, però es pot veure cóm s'utilitza tot):
<record model="ir.actions.server" id="escoleta.creaar_dia_menjador">
<field name="name">Creacio de un dia de menjador a partir d'una plantilla d'alumnes</field>
<field name="model_id" ref="model_escoleta_menjador"/>
<field name="state">code</field>
<field name="code">
for r in records:
fecha = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
env['escoleta.menjador_day'].create({'name':fecha,'day':r.id})
log('creat dia menjador',level='info')
for s in r.students:
log('creat alumne',level='info')
env['escoleta.student_day'].create({'name':str(s.name)+" "+str(fecha),'student':s.id,'menjador_day':r.id})
action = {
"type": "ir.actions.act_window",
"view_mode": "tree",
"res_model": "escoleta.menjador_day",
}
</field>
<field name="binding_model_id" ref="escoleta.model_escoleta_menjador"/>
</record>
L'exemple anterior mostra cóm podem crear un action server i executar coses complexes en el servidor sense modificar el codi python del model. Però açò té varis inconvenients: El primer és que estem desplaçant la tasca del controlador a la vista o a una part en mig entre la vista i el controlador. El segon inconvenient és que és més complicat escriure codi python dins d'un XML sense equivocar-se en la indentació. I el inconvenient més important és que no tenim accés a totes les funcions del ORM i biblioteques útils d'Odoo del controlador. Per tant, és recomanable crear una funció en el model i cridar-la:
<record model="ir.actions.server" id="escoleta.creaar_dia_menjador">
<field name="name">Creacio de un dia de menjador a partir d'una plantilla d'alumnes</field>
<field name="model_id" ref="model_escoleta_menjador"/>
<field name="state">code</field>
<field name="code">
action=model.crear_dia_menjador() # Assignar el resultat de la funció a action per refrescar la web
</field>
<field name="binding_model_id" ref="escoleta.model_escoleta_menjador"/>
</record>
Codi de la funció:
def crear_dia_menjador(self):
# En el XML era records i en el python cal extraurer els records de active_ids
records = self.browse(self._context.get('active_ids'))
for r in records:
# Ja es pot treballar millor en dates gràcies a la biblioteca 'fields'
fecha = fields.Datetime.now()
self.env['escoleta.menjador_day'].create({'name':fecha,'day':r.id})
for s in r.students:
self.env['escoleta.student_day'].create({'name':str(s.name)+" "+str(fecha),'student':s.id,'menjador_day':r.id})
return {
# En el XML era action i ací fa falta que retorne el diccionari per assignar-lo a action
"type": "ir.actions.act_window",
"view_mode": "tree",
"res_model": "escoleta.menjador_day",
}
Domains en les actions
En Odoo, el concepte de domain o domini està en varis llocs, encara que el seu funcionament sempre és el mateix. Es tracta d'un criteri de búsqueda o filtre sobre un model. La sintaxi dels domains és como veurem en aquest exemple:
# [(nom_del_field, operador , valor)]
['|',('gender','=','male'),('gender','=','female')]
Com es veu, cada condició va entre parèntesis amb el mon del field i el valor desitjat entre cometes si és un string i amb l'operador entre cometes i tot separat per comes. Les dues condicions tenen un | dabant, que significa la O lògica. Està dabant per utilitzar la notació polaca inversa.
Un action en domain treu vistes per als elements del model que coincideixen en les condicions del domini. El domain és trauit per el model en un where més a la consulta SQL. Per tant, al client no li arriben mai els registres que no pasen el filtre. Els domains en les vistes search el funcionament en la part del model és igual, ja que no ejecuta un action, però fa la mateixa petició javascript.
Exemple de domain en action:
<record id="action_employee" model="ir.actions.act_window">
<field name="name">Employee Male or Female</field>
<field name="res_model">employee.employee</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="domain">['|',('gender','=','male'),('gender','=','female')]</field>
</record>
Actions per a molts records
Quan estem observant un tree, podem veure dalt uns menús desplegables que mostren varies accions que es poden fer als records seleccionats del tree. Com ara eliminar o duplicar. Nosaltres podem crear noves accions que estaran ahí dalt.
Fins ara hem vist accions que s'executen al polsar un menú o un botó. El menú està declarat explícitament i el botó també. Les accions sols són una manera de dir-li al client web cóm ha de demanar les coses i cóm ha de mostrar-les. El client web de Odoo genera moltes part de l'interfície de manera automàtica. En el cas que ens ocupa, el client web atén a un action demanat pel menú lateral, aquest mostra un tree en la finestra corresponent. Però en la definició del tree, sols està la part de les dades. Dalt del tree, el client web mostra una barra de búsqueda i uns menús desplagables dropdown. Aquest menú és generat pel client amb la llista d'accions vinculades al model que està mostrant.
En anteriors versions d'Odoo, s'havia de crear un ir.values amb key2 i alguna cosa més. Però a partir de la versió 11 d'Odoo, la manera més senzilla de vincular un action al menú de dalt és amb aquests fields que ara tenen les actions:
- binding_type: Per defecte és de tipus action, però pot ser action_form_only per mostrar un formulari o report per generar un report.
- binding_model_id: Aquest field serveix per vincular l'action al menú de dalt de les vistes d'eixe model.
Exemple tret del codi d'Odoo 11:
<record id="action_view_sale_advance_payment_inv" model="ir.actions.act_window">
<field name="name">Invoice Order</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">sale.advance.payment.inv</field>
<field name="view_type">form</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="groups_id" eval="[(4,ref('sales_team.group_sale_salesman'))]"/>
<field name="binding_model_id" ref="sale.model_sale_order" />
</record>
Exemple per a accions tipus server:
<record id="action_server_learn_skill" model="ir.actions.server">
<field name="name">Learning</field>
<field name="type">ir.actions.server</field>
<field name="model_id" ref="your_module_folder_name.model_your_model" />
<field name="binding_model_id" ref="module_folder_name.model_your_target_model" />
<field name="state">code</field>
<field name="code">model.action_learn()</field>
</record>
Per saber més de les actions, podem estudiar el codi: [5]
La vista
Les vistes són la manera en la que es representen els models. En cas de que no declarem les vistes, es poden referenciar per el seu tipus i Odoo generarà una vista de llista o formulari estandar per poder vorer els registres de cada model. No obstant, quasi sempre volem personalitzar les vistes i en aquest cas, es poden referenciar per un identificador.
Les vistes tenen una prioritat i, si no s'especifica el identificador de la que volem mostrar, es mostrarà la que més prioritat tinga.
<record model="ir.ui.view" id="view_id">
<field name="name">view.name</field>
<field name="model">object_name</field>
<field name="priority" eval="16"/>
<field name="arch" type="xml">
<!-- view content: <form>, <tree>, <graph>, ... -->
</field>
</record>
Exemple:
Encara que Odoo ja proporciona un tree i un form per defecte, la vista cal millorar-la quasi sempre. Totes les vistes tenen fields que poden tindre widgets diferents. En les vistes form, podem adaptar molt l'aspecte amb grups de fields, pestanyes, camps ocults condicionalment...
Millores en les vistes tree
En les vistes tree es pot modificar el color en funció del contingut d'un field amb l'etiqueta decoration, que utilitza colors contextuals de Bootstrap:
decoration-bf - Lineas en BOLD decoration-it - Lineas en ITALICS decoration-danger - Color LIGHT RED decoration-info - Color LIGHT BLUE decoration-muted - Color LIGHT GRAY decoration-primary - Color LIGHT PURPLE decoration-success - Color LIGHT GREEN decoration-warning - Color LIGHT BROWN
<tree decoration-info="state=='draft'" decoration-danger="state=='trashed'">
<field name="name"/>
<field name="state"/>
</tree>
En el cas de que es vulga comparar un field Date o Datetime es pot fer amb la variable global de QWeb current_date. Per exemple:
<tree decoration-info="start_date==current_date">
...
També es pot fer editable per no tindre que obrir un form: editable="[top | bottom]". Els trees editables poden tindre un atribut més on_write que indica un mètode a executar quan s'edita o crea un element.
De vegades, un camp pot servir per a alguna cosa, però no cal que l'usuari el veja. El que cal fer és ficar el field , però dir que es invisible="1"
<tree decoration-info="duration==0">
<field name="name"/>
<field name="course_id"/>
<field name="duration" invisible="1"/>
<field name="taken_seats" widget="progressbar"/>
</tree>
Els trees poden tindre buttons amb els mateixos atributs que els buttons dels forms.
En els trees es pot calcular totals amb aquesta etiqueta:
<field name="amount" sum="Total Amount"/>
banner_route
A partir de la versió 12 d'Odoo, permet afegir als trees, forms, etc una capçalera obtinguda per una url. https://www.odoo.com/documentation/12.0/reference/views.html#common-structure
Millores en les vistes form
Per a que un form quede bé, es pot inclure la etiqueta <sheet>, que fa que no ocupe tota la pantalla encara que siga panoràmica.
Tot sheet ha de tindre <group> i dins els fields. Es poden fer els group que vullgam i poden tindre string per mostrar un títol.
Si no utilitzem l'etiquet group, els fields no tindran label, no obstant, coses com el class="oe_edit_only" no funcionen en el group, per tant, cal utilitzar l'etiqueta <label for="name">
Per facilitar la gestió, un form pot tindre pestanyes temàtiques. Es fa en <notebook> <page string="titol">
Es pot separar els grups amb <separator string="Description for Quotations"/>
Alguns One2Many donen una vista tree que no es adequada, per això es pot modificar el tree per defecte:
<field name="subscriptions" colspan="4" mode=”tree”>
<tree>...</tree>
</field>
En un One2many es pot especificar també el form que en donarà quan anem a crear un nou element.
Una altra opció és especificar la vista que insertarà en el field:
<field name="m2o_id" context="{'form_view_ref': 'module_name.form_id'}"/>
Valors per defecte en un one2many
Quant creem un One2many en el mode form (o tree editable) ens permet crear elements d'aquesta relació. Per a aconseguir que, al crear-los, el camp many2one corresponga al pare des del que es crida, es pot fer amb el context: Dins del field one2many que estem fent fiquem aquest codi:
context="{'default_<camp many2one>':active_id}"
O este exemple per a dins d'un action:
<field name="context">{"default_doctor": True}</field>
Domains en Many2ones
Els camps Many2one es poden filtrar, per exemple:
<field name="hotel" domain="[('ishotel', '=', True)]"/>
Widgets
Alguns camps, com ara les imatges, es poden mostrar utilitzant un widget distint que el per defecte:
<field name="image" widget="image" class="oe_left oe_avatar"/>
<field name="taken_seats" widget="progressbar"/>
<field name="country_id" widget="selection"/>
<field name="state" widget="statusbar"/>
Llista de widgets d'Odoo disponibles per a camps dins de forms:
Reescalar les imatges
Molt a sovint, tenim la necessitat de reescalar les imatges que l'usuari penja. Això es fa amb una utilitat d'Odoo en una funció de Python. Per exemple:
from odoo import models, fields, api, tools
[...]
photo = fields.Binary()
photo_small = fields.Binary(compute='_get_images',store=True)
photo_medium = fields.Binary(compute='_get_images',store=True)
@api.one
@api.depends('photo')
def _get_images(self):
image = self.photo
data = tools.image_get_resized_images(image)
self.photo_small = data["image_small"]
self.photo_medium = data["image_medium"]
En Odoo 13 ja no fa falta al tindre un field per a les imatges amb limitació de tamany.
buttons
Podem introduir un botó en el form:
<button name="update_progress" type="object" string="update" class="oe_highlight" /> <!-- El name ha de ser igual que la funció a la que crida. -->
La funció pot ser un workflow, una del model en el que està o un action. En el type cal indicar el tipus amb: workflow, object o action En l'exemple anterior, el button és de tipus object. Aixó vol dir que crida a una funció del model al que represente el formulari que el conté.
Per a fer un butó que cride a un altre formulari, s'ha de fer en un tipus action. Amés, per ficar la id del action al que es vol cridar, cal ficar el prefixe i sufixe %(...)d, com en l'exemple:
<button name="%(launch_mmog_fortress_wizard)d" type="action" string="Launch attack" class="oe_highlight" />
D'aquesta manera, un formulari, té un botó que, al ser polsat, envia el ID de l'action a executar als servidor, aquest li retorna un action per a que el client l'execute. L'action pot obrir una altra finestra o un pop-up. En qualsevol cas, aquest action executat en el client, demana la vista i les dades que vol mostrar i les mostra. Aquesta és la raó de la sintaxis %(...)d. Ja que es tracta d'un External Id a una action guardada en la base de dades.
Els buttons poden tindre una icona. Odoo proporciona algunes que es poden trobar a aquesta web: [6]
<button name="test" icon="fa-star-o" confirm="Are you sure?"/>
Esborrar: <button type="object" icon="fa-trash-o" name="unlink"/>
En l'exemple anterior, també hem ficat l'atribut confirm per mostrar una pregunta a l'usuari. Els buttons es poden posar per el form, encara que es recomana en el header:
<header>
<field name="state" widget="statusbar"/>
<button name="accept" type="object" string="Accept" class="oe_highlight"/>
<button special="cancel" string="Cancel"/>
</header>
Els botons sempre executen una funció de Javascript en la part del client web que demana alguna cosa al servidor. En el cas dels button action, demana el action, per després executar aquesta. En el cas dels buttons object demana que s'execute una funció del model i recordset actual en el servidor. El client web es queda a l'espera d'una resposta del servidor, que si és un diccionari buit, provoca un refresc de la pàgina, però pot retornar moltes coses: warnings, domains, actions... i el client ha d'actuar en conseqüència. Els buttons poden tindre també context per enviar alguna cosa extra al servidor.
Smart Buttons [7]
En el formulari dels client, podem veure aquests botons:
Es tracta de botons que, amés d'executar-se, mostren una informació resumida i una icona. El text i la forma del botó es modifica dinàmicament en funció d'alguns criteris i això li dona més comoditat a l'usuari. Per exemple, si sols vol saber quantes factures té eixe client, el botó li ho diu. Si polsa el botó ja va a les factures en detall.
Per fer-los, el primer és modificar la seua forma, de botó automàticament creat per el navegador a un rectangle. Això odoo ho pot fer per CSS amb la classe class="oe_stat_button". A continuació, se li posa una icona icon="fa-star". [8]. A partir d'ahí, l'etiqueta <button> pot contindre el contingut que desitgem. Per exemple, camps computed que mostren el resum del formulari que va a obrir.
<div class="oe_button_box">
<button type="object" class="oe_stat_button" icon="fa-pencil-square-o" name="regenerate_password">
<div class="o_form_field o_stat_info">
<span class="o_stat_value">
<field name="password" string="Password"/>
</span>
<span class="o_stat_text">Password</span>
</div>
</button>
</div>
Formularis dinàmics
Els fields dels formularis tenen un atribut anomenat attrs que permet modificar el seu comportament en funció de condicions. Per exemple, ocultar amb invisible, permetre ser editat o no amb readonly o required.
Ocultar condicionalment un field
Es pot ocultar un field si algunes condicions no es cumpleixen. Per exemple:
<field name="boyfriend_name" attrs="{'invisible':[('married', '!=', False)]}" />
<field name="boyfriend_name" attrs="{'invisible':[('married', '!=', 'selection_key')]}" />
Tambés es pot ocultar i mostrar sols en el mode edició o lectura:
<field name="partit" class="oe_edit_only"/>
<field name="equip" class="oe_read_only"/>
O mostrar si un camp anomenat state té un determinat valor:
<group states="dia"><field name="dia"/></group>
En el següent exemple, introdueix dos conceptes nous: el column_invisible per ocultar una columna d'un tree i el parent per fer referència al valor d'un field de la vista pare:
<field name="lot_id" attrs="{'column_invisible': [('parent.state', 'not in', ['sale', 'done'])] }"/>
Editar condicionalment un field
En attrs també es pot afegir readonly
<field name="name2" attrs="{'readonly': [('condition', '=', False)]}"/>
Aquests exemples combinen tots els attrs:
<field name="name" attrs="{'invisible': [('condition1', '=', False)],
'required': [('condition2', '=', True)],
'readonly': [('condition3','=',True)]}" />
<field name="suma" attrs="{'readonly':[('valor','=','calculat')],
'invisible': ['|',('servici','in',['Reparacions','Manteniment']),
('client','=','Pepe')]}" />
Workflows
https://www.odoo.com/documentation/8.0/reference/workflows.html
Abans d'explicar els workflows, cal dir que es pot fer alguna cosa pareguda amb les barres d'estatus. Els grups i fields es poden veure o ocultar en funció d'un camp state i afegint l'atribut states="". Amés es pot fer una barra d'estatus
<field name="state" widget="statusbar" statusbar_visible="draft,sent,progress,invoiced,done" />
<button name="action_draft" type="object" string="Reset to draft" states="confirmed,done"/>
Vistes Kanban
Les vistes kanban són per a mostrar el model en forma de 'cartes'. Les vistes kanban se declaren amb una mescla de xml, html i plantilles Qweb.
Un Kanban és una mescla entre tree i form. En Odoo, les vistes tenen una estructura jeràrquica. En el cas del Kanban, està la vista Kanban, que conté molts Kanban Box, un per a cada record mostrat. Cada kanban box té dins un div de class vignette o card i, dins, els Widgets per a cada field.
Window +---------------------------+ | Kanban View | | +----------+ +----------+ | | |Kanban Box| |Kanban Box| | | +----------+ +----------+ | | || Widget || || Widget || | | |----------| |----------| | | |----------| |----------| | | || Widget || || Widget || | | |----------| |----------| | | +----------+ +----------+ | | | +---------------------------+
Per mostrar un Kanban, la vista de Odoo, obri un action Window, dins clava una caixa que ocupa tota la finestra i va recorreguent els records que es tenen que mostrant i dibuixant els widgets de cada record.
Exemple bàsic:
En l'anterior vista kanban cal comentar les línies.
Al principi es declaren els fields que han de ser mostrats. Si no es necessiten per a la lògica del kanban i sols han de ser mostrats no cal que estiguen declarats al principi. No obstant, per que l'exemple estiga complet els hem deixat. Aquesta declaració, fa demanar els fields en la primera petició asíncrona de dades. Els no especificats ací, són demanats després, però no estan disponibles per a que el Javascript puga utilitzar-los.
A continuació ve un template Qweb en el que cal definir una etiqueta <t t-name="kanban-box"> que serà renderitzada una vegada per cada element del model.
Dins del template, es declaren divs o el que necessitem per donar-li el aspecte definitiu. Odoo ja té en el seu CSS unes classes per al productes o partners que podem aprofitar. El primer div defineix la forma i aspecte de cada caixa. Hi ha múltiples classes CSS que es poden utilitzar. Les que tenen vignette en principi no mostren vores ni colors de fons. Les que tenen card tenen el border prou marcat i un color de fons. Les bàsiques són oe_kanban_vignette i oe_kanban_card.
Hi ha molts altres CSS que podem estudiar i utilitzar. Per exemple, els oe_kanban_image per a fer la imatge d'una mida adequada o el oe_product_desc que ajuda a colocar el text al costat de la foto. En l'exemple, usem uns <a> amb dos tipus: open i edit. Segons el que posem, al fer click ens obri el form en mode vista o edició. Aquests botons o enllaços poden tindre aquestes funcions:
- action, object: Com en els botons dels forms, criden a accions o a mètodes.
- open, edit, delete: Efectua aquestes accions al record que representa el kanban box.
Si ja volem fer un kanban més avançat, tenim aquestes opcions:
- En la etiqueta <kanban>:
- default_group_by per agrupar segons algun criteri al agrupar apareixen opcions per crear nous elements sense necessitat d'entrar al formulari.
- default_order per ordenar segons algun criteri si no s'ha ordenat en el tree.
- quick_create a true o false segons vulguem que es puga crear elements sobre la marxa sense el form. Per defecte és false si no està agrupat i true si està agrupat.
- En cada field:
- sum, avg, min, max, count com a funcions d'agregació en els kanbans agrupats.
- Dins del template:
- Cada field pot tindre un type que pot ser open, edit, action, delete.
- Una serie de funcions javascript:
- kanban_image() que accepta com a argument: model, field, id, cache i retorna una url a una imatge. La raó és perquè la imatge està en base64 i dins de la base de dades i cal convertir-la per mostrar-la.
- kanban_text_ellipsis(string[, size=160]) per acurtar textos llargs, ja que el kanban sols és una previsualització.
- kanban_getcolor(raw_value) per a obtindre un color dels 0-9 que odoo te predefinits en el CSS a partir de qualsevol field bàsic.
- kanban_color(raw_value) Si tenim un field color que pot definir de forma específica el color que necessitem. Aquest field tindrà un valor de 0-9.
Un exemple més complet i correcte:
I ara el kanban del magatzem que és realment potent:
Forms dins de kanbans:
A partir de la versió 12 es pot introduir un form dins d'un kanban, encara que es recomana que siga simple. Aquest funciona si tenim activat el quick_create i preferiblement quan el kanban està agrupat per Many2one o altres. Observem, per exemple el kanban de la secció de tasques del mòdul de proyecte:
<kanban default_group_by="stage_id" class="o_kanban_small_column o_kanban_project_tasks" on_create="quick_create"
quick_create_view="project.quick_create_task_form" examples="project">
....
</kanban>
Com podem observar, té activat el quick_create i una referència al identificador extern d'una vista form en quick_create_view. Aquest és el contingut del form:
<?xml version="1.0"?>
<form>
<group>
<field name="name" string="Task Title"/>
<field name="user_id" options="{'no_open': True,'no_create': True}"/>
</group>
</form>
Vistes search
Les vistes search tenen 3 tipus:
- field que permeten buscar en un determinat camp.
- filter amb domain per filtrar per un valor predeterminat.
- filter amb group per agrupar per algun criteri.
Pel que fa a les search field, sols cal indicar quins fields seran buscats.
<search>
<field name="name"/>
<field name="inventor_id"/>
</search>
Les field poden tindre un domain per especificar quin tipus de búsqueda volem. Per exemple:
<field name="description" string="Name and description"
filter_domain="['|', ('name', 'ilike', self), ('description', 'ilike', self)]"/>
Busca per ‘name’ i ‘description’ amb un domini que busca que es parega en “case-insensitive” (ilike) el que escriu l’usuari (self) amb el name o amb la descripció.
o:
<field name="cajones" string="Boxes or @" filter_domain="['|',('cajones','=',self),('arrobas','=',self)]"/>
Busca per cajones o arrobas sempre que l'usuari pose el mateix número.
Les filter amb domain són per a predefinir filtres o búsquedes. Per exemple:
<filter name="my_ideas" string="My Ideas" domain="[('inventor_id', '=', uid)]"/>
<filter name="more_100" string="More than 100 boxes" domain="[('cajones','>',100)]"/>
<filter name="Today" string="Today" domain="[('date', '>=', datetime.datetime.now().strftime('%Y-%m-%d 00:00:00')),
('date', '<=',datetime.datetime.now().strftime('%Y-%m-%d 23:23:59'))]"/>
Operadors per als domains:
Els filter amb group agrupen per algun field:
<group string="Group By">
<filter name="group_by_inventor" string="Inventor" context="{'group_by': 'inventor_id'}"/>
</group>
o:
<filter name="group_by_matricula" string="Matricula" context="{'group_by': 'matricula'}"/>
Si agrupem per data, el grup és per defecte per cada mes, si volem agrupar per dia:
<filter name="group_by_exit_day" string="Exit" context="{'group_by': 'exit_day:day'}"/>
Si volem que un filtre estiga predefinit s'ha de posar en el context de l'action:
<field name="context">{'search_default_clients':1,"default_is_client": True}</field>
En aquest exemple, filtra amb en search_default_XXXX que activa el filtre XXXX i, amés, fa que en els formularis tiguen un camp boolean a true.
Vistes Calendar
Si el recurs té un camp date o datetime. Permet editar els recursos ordenats per temps. L’exemple són els esdeveniments del mòdul de ventes.
- string, per al títol de la vista
- date_start, que ha de contenir el nom d’un camp datetime o date del model.
- date_delay, que ha de contenir la llargada en hores de l’interval.
- date_stop, Aquest atribut és ignorat si existeix l’atribut date_delay.
- day_length, per indicar la durada en hores d’un dia. OpenObject utilitza aquest valor per calcular la data final a partir del valor de date_delay. Per defecte, el seu valor és 8 hores.
- color, per indicar el camp del model utilitzat per distingir, amb colors, els recursos mostrats a la vista.
- mode, per mostrar l’enfoc (dia/setmana/mes) amb el què s’obre la vista. Valors possibles: day, week, month. Per defecte, month.
<record model="ir.ui.view" id="session_calendar_view">
<field name="name">session.calendar</field>
<field name="model">openacademy.session</field>
<field name="arch" type="xml">
<calendar string="Session Calendar" date_start="start_date"
date_stop="end_date"
color="instructor_id">
<field name="name"/>
</calendar>
</field>
</record>
Vistes Graph
Pot contenir els següents atributs:
- string, per al títol de la vista
- type, per al tipus de gràfic. (no fa cas en Odoo 9)
- orientation
La definició dels elements fills de l’element arrel graph determina el contingut del gràfic:
- Primer camp: eix X (horitzontal). Obligatori.
- Segon camp: eix Y (vertical). Obligatori.
- Tercer camp: eix Z (tridimensionals). Optatiu.
A cadascun dels camps que determinen els eixos, se’ls pot aplicar els atributs següents:
- group=“True”, a utilitzar en el segon o tercer camp, per indicar que cal agrupar tots els recursos d’igual valor per aquest camp. El primer camp sempre actua amb group=“True”. Per la resta de camps, s’aplica l’operador indicat a l’atribut operator.
- operator, per indicar l’operador a utilitzar per calcular el valor en la resta dels camps, com a conseqüència de l’agrupació indicada a l’atribut group. Els operadors permesos són: + (suma), *(producte), **(exponent), min i max (menor i major valors de la llista de valors de l’agrupació). Per defecte, actua l’operador +.
<record model="ir.ui.view" id="openacademy_session_graph_view">
<field name="name">openacademy.session.graph</field>
<field name="model">openacademy.session</field>
<field name="arch" type="xml">
<graph string="Participations by Courses">
<field name="course_id"/>
<field name="attendees_count" type="measure"/>
</graph>
</field>
</record>
Si volem ficar-lo dins d'un form comun camp one2many, cal especificar un domain:
<field name="id"/> <!-- És necessari el camp id, pot ser invisible -->
<field name="rounds" mode="tree,graph" domain="[('team','=', id)]" >
<graph string="Points" type="line">
<field name="round"/>
<field name="point_v" />
</graph>
El type="line" sembla no funcionar en Odoo 9 , per tant es deu ficar en el context:
<field name="context">{'graph_mode':'line'}</field>
Els reports
El nou motor de reports utilitza una combinació de QWeb, BootStrap i Wkhtmltopdf.
Un report consta de dos elements:
- Un registre en la base de dades en el model: ir.actions.report.xml amb els paràmetres bàsics
- Una vista Qweb per al contingut.
Per exemple, en el xml:
<report
id="report_session"
model="openacademy.session"
string="Session Report"
name="openacademy.report_session_view"
file="openacademy.report_session"
report_type="qweb-pdf" />
<template id="report_session_view">
<t t-call="report.html_container">
<t t-foreach="docs" t-as="doc">
<t t-call="report.external_layout">
<div class="page">
<h2 t-field="doc.name"/>
<p>From <span t-field="doc.start_date"/> to <span t-field="doc.end_date"/></p>
<h3>Attendees:</h3>
<ul>
<t t-foreach="doc.attendee_ids" t-as="attendee">
<li><span t-field="attendee.name"/></li>
</t>
</ul>
</div>
</t>
</t>
</t>
</template>
Els reports simplifiquen amb l'etiqueta report la creació d'un action de tipus report. Automàticament situen un botó dalt del tree o form per imprimir.
Una mínima template que funciona:
<template id="report_invoice">
<t t-call="report.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="report.external_layout">
<div class="page">
<h2>Report title</h2>
<p>This object's name is <span t-field="o.name"/></p>
</div>
</t>
</t>
</t>
</template>
Analitzem aquesta template:
- external_layout: Afegeix la capçalera i el peu per defecte de Odoo.
- Dins de : Està el contingut del report.
- id: A de ser el mateix que el name del report.
- docs: Llista d'objectes a imprimir. (Paregut a self)
Es poden afegir css locals o externs al report heredant el template e insertant el css:
<template id="report_saleorder_style" inherit_id="report.layout">
<xpath expr="//style" position="after">
<style type="text/css">
.example-css-class {
background-color: red;
}
</style>
</xpath>
</template>
Per afegir una imatge de la base de dades:
<span t-field="doc.logo" t-field-options="{"widget": "image", "class": "img-rounded"}"/>
Notes sobre QWeb
QWeb és el motor de plantilles de Odoo. Els elements són etiquetes XML que comencen per t-
- t-field: Per mostrar el contingut d'un field
- t-if: Per fer condicionals. Per fer un condicional en funció de si un field està o no, sols cal ficar el field en questió dins del condicional.
<t t-if="viatge.hotel">
<!-- ... -->
</t>
- t-foreach: Per fer bucles per els elements d'un one2many, per exemple.
Depurar els reports
Because reports are standard web pages, they are available through a URL and output parameters can be manipulated through this URL, for instance the HTML version of the Invoice report is available through http://localhost:8069/report/html/account.report_invoice/1 (if account is installed) and the PDF version through http://localhost:8069/report/pdf/account.report_invoice/1.
Més informació https://www.odoo.com/documentation/8.0/reference/reports.html
Herència
El framework d'Odoo facilita el mecanisme de l’herència per tal que els programadors puguin adaptar mòduls existents i garantir a la vegada que les actualitzacions dels mòduls no destrossin les adequacions desenvolupades.
L’herència es pot aplicar en els tres components del patró MVC:
- En el model: possibilita ampliar les classes existents o dissenyar noves classes a partir de les existents.
- En la vista: possibilita modificar el comportament de vistes existents o dissenyar noves vistes.
- En el controlador: possibilita sobreescriure els mètodes existents o dissenyar-ne de nous.
OpenObject proporciona tres mecanismes d’herència: l’herència de classe, l’herència per prototip i l’herència per delegació.
Mecanisme | Característiques | Com es defineix |
---|---|---|
De classe | - Herència simple. - La classe original queda substituïda per la nova classe. |
- S’utilitza l’atribut _inherit en la definició de la nova classe Python: _inherit = obj - El nom de la nova classe ha de continuar sent el mateix que el de la classe original: |
Per prototip | - Herència simple. - Aprofita la definició de la classe original (com si fos un «prototipus»). |
- S’utilitza l’atribut _inherit en la definició de la nova classe Python: _inherit = obj - Cal indicar el nom de la nova classe: |
Per delegació | - Herència simple o múltiple. - La nova classe «delega» certs funcionaments a altres classes que incorpora a l’interior. |
- S’utilitza l’atribut _inherits en la definició de la nova classe Python: _inherits = … - Cal indicar el nom de la nova classe: |
El fitxer __openerp__.py ha de contindre les dependències de la clase heretada.
Herència en el Model
El disseny d’un objecte d’OpenObject heretat és paregut al disseny d’un objecte d’OpenObject no heretat; únicament hi ha dues diferències:
- Apareix l’atribut _inherit o _inherits per indicar l’objecte (herència simple) o els objectes (herència múltiple) dels quals deriva el nou objecte. La sintaxi a seguir és:
_inherit = 'nom.objecte.del.que.es.deriva' _inherits = {'nom.objecte1':'nom_camp_FK1', ...}
- En cas d’herència simple, el nom (atribut _name) de l’objecte derivat pot coincidir o no amb el nom de l’objecte pare. També és possible no indicar l’atribut _name, fet que indica que el nou objecte manté el nom de l’objecte pare.
L’herència simple (_inherit) amb atribut _name idèntic al de l’objecte pare, s’anomena herència de classe i en ella el nou objecte substitueix l’objecte pare, tot i que les vistes sobre l’objecte pare continuen funcionant. Aquest tipus d’herència, la més habitual, s’utilitza quan es vol afegir fields i/o modificar propietats de dades existents i/o modificar el funcionament d’alguns mètodes. En cas d’afegir dades, aquestes s’afegeixen a la taula de la base de dades en la qual estava mapat l’objecte pare.
Exemple d'herència de classe L’herència de classe la trobem en molts mòduls que afegeixen dades i mètodes a objectes ja existents, com per exemple, el mòdul comptabilitat (account) que afegix dades i mètodes a l’objecte res.partner. Fixem-nos en el contingut del mòdul account:
class res_partner(Model.model):
_name = 'res.partner'
_inherit = 'res.partner'
debit_limit = fields.float('Payable limit')
...
Podeu comprovar que la taula res_partner d’una empresa sense el mòdul account instal·lat no conté el camp debit_limit, que en canvi sí que hi apareix una vegada instal·lat el mòdul.
Odoo té molts mòduls que deriven de l’objecte res.partner per afegir-hi característiques i funcionalitats.
L’herència simple (_inherit) amb atribut _name diferent al de l’objecte pare, s’anomena herència per prototip i en ella es crea un nou objecte que aglutina les dades i mètodes que tenia l’objecte del qual deriva, juntament amb les noves dades i mètodes que pugua incorporar el nou objecte. En aquest cas, sempre es crea una nova taula a la base de dades per mapar el nou objecte.
Exemple d'herència per prototip L’herència per prototip és difícil de trobar en els mòduls que incorpora Odoo. Un exemple el tenim en el mòdul base_calendar en el qual podem observar el mòdul comptabilitat (account) que afegix dades i mètodes a l’objecte res.partner. Fixem-nos en el contingut del mòdul account:
class res_alarm(Model.model):
_name = 'res.alarm'
...
class calendar_alarm(Model.model):
_name = 'calendar.alarm'
_inherit = 'res.alarm'
...
En una empresa que tingui el mòdul base_calendar instal·lat podeu comprovar l’existència de la taula res_alarm amb els camps definits a l’apartat _atributs de la classe res_alarm i la taula calendar_alarm amb camps idèntics als de la taula res_alarm més els camps definits a l’apartat _atributs de la classe calendar_alarm.
L’herència múltiple (_inherits) s’anomena herència per delegació i sempre provoca la creació d’una nova taula a la base de dades. L’objecte derivat ha d’incloure, per cada derivació, un camp many2one apuntant l’objecte del qual deriva, amb la propietat ondelete='cascade'. L’herència per delegació obliga que cada recurs de l’objecte derivat apunte a un recurs de cadascun dels objectes dels quals deriva i es pot donar el cas que hi hagi diversos recursos de l’objecte derivat que apunten a un mateix recurs per algun dels objectes dels quals deriva.
class res_alarm(Model.model):
_name = 'res.alarm'
...
class calendar_alarm(Model.model):
_name = 'calendar.alarm'
_inherits = {'res.alarm':'alarm_id'}
...
Herència en la vista
L’herència de classe possibilita continuar utilitzant les vistes definides sobre l’objecte pare, però en moltes ocasions interessa disposar d’una versió retocada. En aquest cas, és molt millor heretar de les vistes existents (per afegir, modificar o eliminar camps) que reemplaçar-les completament.
<field name="inherit_id" ref="id_xml_vista_pare"/>
En cas que la vista id_xml_vista_pare estiga en un mòdul diferent del que estem dissenyant, cal afegir el nom del mòdul al davant:
<field name="inherit_id" ref="modul.id_xml_vista_pare"/>
El motor d’herència d’OpenObject, en trobar una vista heretada, processa el contingut de l’element arch. Per cada fill d’aquest element que tingui algun atribut, OpenObject cerca a la vista pare una etiqueta amb atributs coincidents (excepte el de la posició) i, a continuació, combina els camps de la vista pare amb els de la vista heretada i estableix la posició de les noves etiquetes a partir dels següents valors:
- inside (per defecte): els valors s’afegeixen “dins” de l’etiqueta.
- after: afegeix el contingut després de l’etiqueta.
- before: afegeix el contingut abans de l’etiqueta.
- replace: reemplaça el contingut de l’etiqueta.
- attributes: Modifica els atributs.
Reemplaçar
<field name="arch" type="xml">
<field name="camp" position="replace">
<field name="nou_camp" ... />
</field>
</field>
Esborrar
<field name="arch" type="xml">
<field name="camp" position="replace"/>
</field>
Inserir nous camps
<field name="arch" type="xml">
<field name="camp" position="before">
<field name="nou_camp" .../>
</field>
</field>
<field name="arch" type="xml" style="font-family:monospace">
<field name="camp" position="after">
<field name="nou_camp" .../>
</field>
</field>
Fer combinacions
<field name="arch"type="xml">
<data>
<field name="camp1" position="after">
<field name="nou_camp1"/>
</field>
<field name="camp2" position="replace"/>
<field name="camp3" position="before">
<field name="nou_camp3"/>
</field>
</data>
</field>
Per definir la posició dels elements que afegim, podem utilitzar una expresió xpath:
<xpath expr="//field[@name='order_line']/tree/field[@name='price_unit']" position="before">
<xpath expr="//form/*" position="before">
<header>
<field name="status" widget="statusbar"/>
</header>
</xpath>
És posssible que necessitem una vista totalment nova de l'objecte heredat. Si fem un action normal en l'XML es veuran els que més prioritat tenen. Si volem especificar quina vista volem en concret hem d'utilitzar view_id:
<field name="view_id" ref="view_school_parent_form2"/>
Tal vegada cal especificar totes les vistes. En eixe cas, s'ha de guardar per separat en ir.actions.act_window.view:
<record model="ir.actions.act_window" id="action_my_hr_employee_seq">
<field name="name">Angajati</field>
<field name="res_model">hr.employee</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
</record>
<record model="ir.actions.act_window.view" id="act_hr_employee_tree_view">
<field eval="1" name="sequence"/>
<field name="view_mode">tree</field>
<field name="view_id" ref="your_tree_view_id"/>
<field name="act_window_id" ref="action_my_hr_employee_seq"/>
</record>
<record model="ir.actions.act_window.view" id="act_hr_employee_form_view">
<field eval="2" name="sequence"/>
<field name="view_mode">form</field>
<field name="view_id" ref="your_form_view_id"/>
<field name="act_window_id" ref="action_my_hr_employee_seq"/>
</record>
Encara que és més simple referenciar i crear aquestes relacions dirèctament en l'action, observem aquest exemple:
<record model="ir.actions.act_window" id="terraform.player_action_window">
<field name="name">Players</field>
<field name="res_model">res.partner</field>
<field name="view_mode">tree,form,kanban</field>
<field name="domain"> [('is_player','=',True)]</field>
<field name="context">{'default_is_player': True}</field>
<field name="view_ids" eval="[(5, 0, 0),
(0, 0, {'view_mode': 'tree', 'view_id': ref('terraform.player_tree')}),
(0, 0, {'view_mode': 'form', 'view_id': ref('terraform.player_form')}),]" />
</record>
En realitat estem fent el mateix, sols que en (0,0,{registre_a_crear}) li diguem que a eixe Many2many hi ha que afegir un nou registre amb eixes dades en concret. El que necessita és el view_mode i el view_id, com en els records anteriors.
Si es vol especificar una vista search es pot inclourer la etiqueta search_view_id:
<field name="search_view_id" ref="cine.pos_order_line_search_view"/>
Exemple:
Domains
Si volem que el action heredat sols mostre els elements que volem, s'ha de ficar un domain en el action:
<field name="domain"> [('isplayer','=',True)]</field>
Amés, es pot dir que, per defecte, quan es crea un nou registre a través d'aquest action, tinga el field a True:
<field name="context">{'default_is_player': True}</field>
Filtre per defecte
El problema en la solució anterior és que lleva la possibilitat de veure el que no tenen aquest field a True i cal anar per un altre action a modificar-los. Si volem poder veure tots, podem crear un filtre en la vista search i en l'action dir que volem aquest filtre per defecte:
<!-- En la vista search -->
...
<search>
<filter name="player_partner" string="Is Player" domain="[('is_player','=',True)]" />
</search>
...
<!-- En l'action -->
<!-- <field name="domain"> [('is_player','=',True)]</field> -->
<field name="domain"></field>
<field name="context">{'default_is_player': True, 'search_default_player_partner': 1}</field>
Herència en el controlador
L’herència en el controlador és un mecanisme conegut, ja que l’apliquem de forma inconscient quan ens veiem obligats a sobreescriure els mètodes de la capa ORM d’OpenObject en el disseny de molts mòduls.
L’efecte de l’herència en el controlador es manifesta únicament quan cal sobreescriure algun dels mètodes de l’objecte del qual es deriva i per a fer-ho adequadament cal tenir en compte que el mètode sobreescrit en l’objecte derivat:
- A vegades vol substituir el mètode de l’objecte base sense aprofitar-ne cap funcionalitat: el mètode de l’objecte derivat no invoca el mètode sobreescrit.
- A vegades vol aprofitar la funcionalitat del mètode de l’objecte base: el mètode de l’objecte derivat invoca el mètode sobreescrit.
Exemples:
El controlador
Part del controlador l'hem mencionat al parlar dels camps computed. No obstant, cal comentar les facilitats que proporciona Odoo per a no tindre que accedir dirèctament a la base de dades.
La capa ORM d’Odoo facilita uns mètodes que s’encarreguen del mapatge entre els objectes Python i les taules de PostgreSQL. Així, disposem de mètodes per crear, modificar, eliminar i cercar registres a la base de dades.
En ocasions, pot ser necessari alterar l’acció automàtica de cerca – creació – modificació – eliminació facilitada per Odoo i haurem de sobreescriure els corresponents mètodes en les nostres classes.
Els programadors en el framework d'Odoo hem de conèixer els mètodes subministrats per la capa ORM i hem de dominar el disseny de mètodes per:
- Poder definir camps funcionals en el disseny del model.
- Poder definir l’acció que cal executar en modificar el contingut d’un field d’una vista form (atribut on_change del field)
- Poder alterar les accions automàtiques de cerca, creació, modificació i eliminació de recursos.
Una darrera consideració a tenir en compte en l’escriptura de mètodes i funcions en Odoo és que els textos de missatges inclosos en mètodes i funcions, per poder ser traduïbles, han de ser introduïts amb la sintaxi _('text') i el fitxer .py ha de contenir from tools.translate import _ a la capçalera.
API de l'ORM
$ odoo shell -d castillo -u containers Asciinema amb alguns exemplesObserva cóm hem ficat el paràmetre shell. Les coses que se fan en la terminal no són persistents en la base de dades fins que no s'executa self.env.cr.commit(). Dins de la terminal podem obtindre ajuda dels mètodes d'Odoo amb help(), per exemple: help(tools.image)
Un mètode creat dins d'un model actua sobre tots els elements del model que estiguen actius en el moment de cridar al mètode. Si és un tree, seran molts i si és un form sols un. Però en qualsevol cas és una 'llista' d'elements i es diu recordset.
Bàsicament la interacció amb els models en el controlador es fa amb els anomenats recordsets que són col·leccions d'objectes sobre un model. Si iterem dins dels recordset , obtenim els singletons, que són objectes individuals de cada línia en la base de dades.
def do_operation(self):
print self # => a.model(1, 2, 3, 4, 5)
for record in self:
print record # => a.model(1), then a.model(2), then a.model(3), ...
Podem accedir a tots els fields d'un model sempre que estem en un singleton, no en un recordset:
>>> record.name
Example Name
>>> record.company_id.name
Company Name
>>> record.name = "Bob"
Intentar llegir o escriure un field en un recordset donarà un error. Accedir a un many2one, one2many o many2many donarà un recordset.
Set operations Els recordsets es poden combinar amb operacions específiques que són les típiques dels conjunts:
- record in set retorna si el record està en el set
- set1 | set2 Unió de sets
- set1 & set2 Intersecció de sets
- set1 - set2 Diferència de sets
- filtered() Filtra el recordset de manera que sols tinga els records que complixen una condició.
records.filtered(lambda r: r.company_id == user.company_id)
records.filtered("partner_id.is_company")
- sorted() Ordena segons uns funció, se defineix una funció lambda (key) que indica que s'ordena per el camp name:
# sort records by name
records.sorted(key=lambda r: r.name)
records.sorted(key=lambda r: r.name, reverse=True)
- mapped() Li aplica una funció a cada recordset i retorna un recordset amb els canvis demanats:
# returns a list of summing two fields for each record in the set
records.mapped(lambda r: r.field1 + r.field2)
# returns a list of names
records.mapped('name')
# returns a recordset of partners
record.mapped('partner_id')
# returns the union of all partner banks, with duplicates removed
record.mapped('partner_id.bank_ids')
Aquestes funcions són útils per a fer tècniques de programació funcional
Enviroment
L'anomenat enviroment o env guarda algunes dades contextuals interessants per a treballar amb l'ORM, com ara el cursor a la base de dades, l'usuari actual o el context (que guarda algunes metadades).
Tots els recordsets tenen un enviroment accesible amb env. Quant volem crear un recordset dins d'un altre, podem usar env:
>>> self.env['res.partner']
res.partner
>>> self.env['res.partner'].search([['is_company', '=', True], ['customer', '=', True]])
res.partner(7, 18, 12, 14, 17, 19, 8, 31, 26, 16, 13, 20, 30, 22, 29, 15, 23, 28, 74)
El primer cas crea un recordset buit però que fa referència a res.partner i es poden fer les funcions de l'ORM que necessitem.
Context
El context és un diccionari de python que conté dades útils per a totes les vistes i els mètodes. Les funcions d'Odoo reben el context i el consulten si cal. Context pot tindre de tot, però quasi sempre té al menys el user ID, l'idioma o la zona temporal. Quant Odoo va a renderitzar una vista XML, consulta el context per veure si ha d'aplicar algun paràmetre.
print(self.env.context)
Al llarg de tot aquest manual utilitzem sovint paràmetres del context. Aquests són els paràmetres que hem utilitzat en algun moment:
- active_id : self._context.get('active_id') es tracta de l'id de l'element del model que està en pantalla.
- active_ids : Llista de les id seleccionats en un tree.
- active_model : El model actual.
- default_<field> : En un action o en un one2many es pot assignar un valor per defecte a un field.
- search_default_<filter> : Per aplicar un filtre per defecte a la vista en un action.
- group_by : Dins d'un camp filter per a crear agrupacions en les vistes search.
- graph_mode : En les vistes graph, aquest paràmetre canvia el type
El context va passant d'un mètode a un altre o a les vistes i, de vegades volem modificar-lo.
Imaginem que volem fer un botó que obriga un wizard, però volem passar-li paràmetres al wizard. En els botons i fields relacionals es pot especificar un context:
<button name="%(reserves.act_w_clients_bookings)d" type="action" string="Select bookings" context="{'b_fs':bookings_fs}"/>
Eixe action obre un wizard, que és un model transitori en el que podem definir un field amb els continguts del context:
def _default_bookings(self):
return self._context.get('b_fs')
bookings_fs = fields.Many2many('reserves.bookings',readonly=True, default=_default_bookings)
Aquest many2many tindrà els mateixos elements que el form que l'ha cridat. (Això és com el default_ en els One2many, però fet a mà)
També es pot utilitzar aquesta manera d'enviar un recordset per un context per al domain d'un field Many2one o Many2many:
def _domain_bookings(self):
return [('id','=',self._context.get('b_fs').ids)]
bookings_fs = fields.Many2many('reserves.bookings',readonly=True, domain=_default_bookings)
El context és un diccionari inmutable (frozendict) que no pot ser alterat en funcions. no obstant, si volem modificar el context actual per enviar-lo a un action o cridar a una funció d'un model amb un altre context, es pot fer amb with_context:
# current context is {'key1': True}
r2 = records.with_context({}, key2=True)
# -> r2._context is {'key2': True}
r2 = records.with_context(key2=True)
# -> r2._context is {'key1': True, 'key2': True}
Si és precís modificar el context es pot fer:
self.env.context = dict(self.env.context)
self.env.context.update({'key': 'val'})
o
self = self.with_context(get_sizes=True)
print self.env.context
Però no funciona més enllà del recordset actual. És a dir, no modifica el context en el que s'ha cridat.
Si el que volem és passar el valor d'un field per context a un botó dins d'una 'subvista', podem utilitzar el paràmetre parent, que funciona tant en en domain, attr, com en context. Ací tenim un exemple de tree dins d'un field amb botons que envíen per context coses del pare:
<field name="movies" >
<tree>
<field name="photo_small"/>
<field name="name"/>
<field name="score" widget='priority'/>
<button name="book_it" string="Book it" type="object" context="{'b_client':parent.client,'b_day':parent.day}"/>
</tree>
Mètodes de l'ORM
search()
A partir d'un domain de Odoo, proporciona un recordset amb tots els elements que coincideixen:
>>> # searches the current model
>>> self.search([('is_company', '=', True), ('customer', '=', True)])
res.partner(7, 18, 12, 14, 17, 19, 8, 31, 26, 16, 13, 20, 30, 22, 29, 15, 23, 28, 74)
>>> self.search([('is_company', '=', True)], limit=1).name
'Agrolait'
Parameters
args -- A search domain. Use an empty list to match all records.
offset (int) -- number of results to ignore (default: none)
limit (int) -- maximum number of records to return (default: all)
order (str) -- sort string
count (bool) -- if True, only counts and returns the number of matching records (default: False)
create()
Te dona un recordset a partir d'una definició de varis fields:
>>> self.create({'name': "New Name"})
res.partner(78)
write()
Escriu uns fields dins de tots els elements del recordset, no retorna res:
self.write({'name': "Newer Name"})
Escriure en un many2many:
La manera més senzilla és passar una llista d'ids. Però si ja existeixen elements abans, necessitem uns codis especials (vegeu Odoo#Expressions):
Per exemple:
self.sessions = [(4,s.id)]
self.write({'sessions':[(4,s.id)]})
self.write({'sessions':[(6,0,[ref('vehicle_tag_leasing'),ref('fleet.vehicle_tag_compact'),ref('fleet.vehicle_tag_senior')] )]})
browse()
A partir d'una llista de ids, retorna un recordset.
>>> self.browse([7, 18, 12])
res.partner(7, 18, 12)
exists()
Retorna si un record en concret encara està en la base de dades.
if not record.exists():
raise Exception("The record has been deleted")
o:
records.may_remove_some()
# only keep records which were not deleted
records = records.exists()
En el segon exemple, refresca un recordset amb aquells que encara existixen.
ref()
Retorna un singleton a partir d'un External ID.
>>> env.ref('base.group_public')
res.groups(2)
ensure_one()
S'asegura de que el record en concret siga un singleton.
records.ensure_one()
# is equivalent to but clearer than:
assert len(records) == 1, "Expected singleton"
unlink()
Esborra de la base de dades els elements del recordset actual.
Exemple de cóm sobreescriure el mètode unlink per a esborrar en cascada:
@api.multi
def unlink(self):
for x in self:
x.catid.unlink()
return super(product_uom_class, self).unlink()
read() Es tracta d'un mètode de baix nivell per llegir un field en concret dels records. És preferible emprar browse()
name_search(name=, args=None, operator='ilike', limit=100) → records Search for records that have a display name matching the given name pattern when compared with the given operator, while also matching the optional search domain (args).
This is used for example to provide suggestions based on a partial value for a relational field. Sometimes be seen as the inverse function of name_get(), but it is not guaranteed to be.
This method is equivalent to calling search() with a search domain based on display_name and then name_get() on the result of the search.
ids Llista dels ids del recordset actual.
sorted(key=None, reverse=False) Retorna el recordset ordenat per un criteri.
name_get() Retorna el nom que tindrà el record quant siga referenciat externament. És el valor per defecte del field display_name. Aquest mètode, per defecte, mostra el field name si està. Es pot sobreescriure per mostrar un altre camp o mescla d'ells.
copy() Crea una còpia del singleton i permet aportar nous valors per als fields de la copia.
En els fields One2many no es pot copiar per defecte, però es pot dir copy=True.
onchange
Si volem que un valor siga modificat en temps real quant modifiquem el valor d'un altre field sense encara haver guardat, podem usar els mètodes on_change.
En onchange es modifica el valor d'un o més camps dirèctament i, si cal un filtre o un missatge, es fa en el return:
return {
'domain': {'other_id': [('partner_id', '=', partner_id)]},
'warning': {'title': "Warning", 'message': "What is this?", 'type': 'notification'},
}
Si el type és notification es mostrarà en una notificació, en un altre cas, en un dialog. (Odoo 13)
Exemples:
# onchange handler
@api.onchange('amount', 'unit_price')
def _onchange_price(self):
# set auto-changing field
self.price = self.amount * self.unit_price
# Can optionally return a warning and domains
return {
'warning': {
'title': "Something bad happened",
'message': "It was very bad indeed",
}
}
@api.onchange('seats', 'attendee_ids')
def _verify_valid_seats(self):
if self.seats < 0:
return {
'warning': {
'title': "Incorrect 'seats' value",
'message': "The number of available seats may not be negative",
}, }
if self.seats < len(self.attendee_ids):
return {
'warning': {
'title': "Too many attendees",
'message': "Increase seats or remove excess attendees",
},
}
@api.onchange('pais')
def _filter_empleat(self):
return { 'domain': {'empleat': [('country','=',self.pais.id)]} }
# Exemple avançat en el que l'autor crea un domain amb una llista d'ids i un '''in''':
@api.multi
def onchange_partner_id(self, part):
res = super(SaleOrder, self).onchange_partner_id(part)
domain = [('active', '=', True), ('sale_ok', '=', True)]
if part:
partner = self.env['res.partner'].browse(part)
if partner and partner.sales_channel_id:
domain.append(('sales_channel_ids', '=',
partner.sales_channel_id.id))
product_ids = self.env['product.product'].search(domain)
res.update(domain={
'order_line.product_id': ['id', 'in', [rec.id for rec in product_ids]]
})
return res
- Constraints
- onchange amb missatge d'error i restablint els valors originals
- Sobreescriptura del mètode write o create per comprovar coses abans de guardar
Els Decoradors
Com es veu, abans de moltes funcions es fica @api.depends, @api.multi...
Els decoradors modifiquen la forma en la que és cridada la funció. Entre altres coses, modifiquen el contingut de self, les vegades que se crida i quant se crida.
- @api.multi: En aquest cas, self conté un recordset amb tots les instàncies del model que estiguen visibles. Si és un tree seran totes les visibles i en un form sols la que està en eixe moment. En qualsevol cas, és recomanable fer un for que les recórrega. En els camps computed s'executa sempre que no siguen store=True. També serveix per a botons.
- @api.one Per a aquest decorador, self és un sol element. Si és cridat en un tree, la funció és cridada una vegada per a cada element. Pot simplificar la tasca en botons en els formularis. Es considera obsolet
- @api.depends() Aquest decorador crida a la funció sempre que el camp del que depén siga modificat. Encara que el camp diga store=True. Per defecte, self és com en @api.multi.
- @api.model S'utilitza sobretot per a transformar peticions d'Openerp 7 a Odoo. Per defecte és com @api.multi
- @api.constrains() S'utilitza per a comprovar les constrains. Self és un recordset, com en @api.multi. Com que quasi sempre es crida en un form, funciona si utilitzem self directament. Però cal fer for, ja que pot ser cridat en un recordset quant modifiquem camps en grup.
- @api.onchange() S'executa cada vegada que modifiquem el field indicat en la vista. En aquest, com que es crida quant es modifica un form, sempre self serà un singleton. Però si fiquem un for no passa res.
Exemple de tots els decoradors: (Odoo 12)
Casos resolts del controlador
1er Cas, el % de caixons:
2on cas, generador de partides:
3er Cas, les batalles:
4t Cas, els noms del les fortaleses (El tema del name_get):
Wizards
Els wizards permeten fer un asistent interactiu per a que l'usuari complete una tasca. Com que no ha d'agafar les dades dirèctament en un formulari, si no que va ajundant-lo a completar-lo, no pot ser guardat en la base de dades fins al final.
Els wizards en Odoo són models que estenen la classe TransientModel en compte de Model. Aquesta classe és molt pareguda, però:
- Les dades no són persistents, encara que es guarden temporalment en la base de dades.
- No necessiten permisos explícits.
- Els records dels wizards poden tindre referències Many2One amb el records dels models normals, però no al contrari.
class wizard(models.TransientModel):
_name = 'mmog.wizard'
def _default_attacker(self):
return self.env['mmog.fortress'].browse(self._context.get('active_id')) # El context conté, entre altre coses,
#el active_id del model que està obert.
fortress_attacker = fields.Many2one('mmog.fortress',default=_default_attacker)
fortress_target = fields.Many2one('mmog.fortress')
soldiers_sent = fields.Integer(default=1)
@api.multi
def launch(self):
if self.fortress_attacker.soldiers >= self.soldiers_sent:
self.env['mmog.attack'].create({'fortress_attacking':self.fortress_attacker.id,
'fortress_defender':self.fortress_target.id,
'data':fields.datetime.now(),'soldiers_sent':self.soldiers_sent})
return {}
En el python cal observar la classe de la que hereta, el default, que extrau el active_id del form que a llançat el wizard i el mètode que és cridat pel botó de la vista.
<record model="ir.ui.view" id="wizard_mmog_fortress_view">
<field name="name">wizard.mmog.fortress</field>
<field name="model">mmog.wizard</field>
<field name="arch" type="xml">
<form string="Select fortress">
<group>
<field name="fortress_attacker"/>
<field name="fortress_target"/>
<field name="soldiers_sent"/>
</group>
<footer>
<button name="launch" type="object"
string="Launch" class="oe_highlight"/>
or
<button special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<act_window id="launch_mmog_fortress_wizard"
name="Launch attack"
binding_model="mmog.fortress"
res_model="mmog.wizard"
view_mode="form"
target="new"
/>
En la vista, tenim creat un form normal amb dos botons. Un d'ells és especial per a cancel·lar el wizard. L'altre crida al mètode. També s'ha creat un action indicant el src_model sobre el que treballa i el model del wizard que utilitza. Els action que criden a wizard tenen l'atribut target a new per a que llance una finestra emergent.
<button name="%(launch_mmog_fortress_wizard)d" type="action" string="Launch attack" class="oe_highlight" />
Si volem, podem ficar un botó que cride al action del wizard. Observem la sintaxi del name, que és igual sempre que el button siga de tipus action, ja que és l'anomenat XML id.
Els wizards poden tindre, al igual que els forms, estats i formen assistents: ✎
En aquest exemple anem a fer una espècie de workflow. Per començar, cal crear un camp state amb varis valors possibles:
state = fields.Selection([
('pelis', "Movie Selection"),
('dia', "Day Selection"),
], default='pelis')
@api.multi
def action_pelis(self):
self.state = 'pelis'
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
@api.multi
def action_dia(self):
self.state = 'dia'
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
I uns botons que van fent que passe d'un estar a un altre:
<header>
<button name="action_pelis" type="object"
string="Reset to movie selection"
states="dia"/>
<button name="action_dia" type="object"
string="Select dia" states="pelis"
class="oe_highlight"/>
<field name="state" widget="statusbar"/>
</header>
<group states="pelis">
<field name="cine"/>
<field name="pelicules"/>
</group>
<group states="dia">
<field name="dia"/>
</group>
Després es pot fer que el formulari tinga un aspecte diferent depèn del valor de state
Els wizards poden tornar a recarregar la vista des de la que són cridats:
return {
'name': 'Reserves',
'view_type': 'form',
'view_mode': 'form', # Pot ser form, tree, kanban...
'res_model': 'wizards.reserves', # El model de destí
'res_id': reserva.id, # El id concret per obrir el form
# 'view_id': self.ref('wizards.reserves_form') # Opcional si hi ha més d'una vista posible.
'context': self._context, # El context es pot ampliar per afegir opcions
'type': 'ir.actions.act_window',
'target': 'current', # Si ho fem en current, canvia la finestra actual.
}
L'exemple anterior és la manera llarga i completa de cridar a una vista en concret, però si sols necessitem refrescar la vista cridada, podem afegir:
return {
'type': 'ir.actions.client',
'tag': 'reload',
}
Exemple Complet de Wizards
Client web
Web Controllers
Pàgina web
https://www.odoo.yenthevg.com/creating-webpages-controllers-odoo10/ http://learnopenerp.blogspot.com/2018/08/odoo-web-controller.html
Exemples
Vídeo de Mòdul Odoo completCodi del vídeo
Misc.
- Si volem fer un print en colors, podem ficar un caracter de escape: \033[93m i \033[0m al final
- Traure la menor potència de 2 major o igual a un número: http://stackoverflow.com/a/14267557
Cron Jobs
Distintes alertes:
Funcions lambda:
Càlculs en dates:
Imatges en Odoo:
Enllaços
https://www.odoo.com/documentation/8.0/ https://www.odoo.com/documentation/9.0/
https://www.odoo.com/documentation/8.0/howtos/backend.html
Blogs: http://ludwiktrammer.github.io/ http://www.odoo.yenthevg.com/ https://sateliteguayana.wordpress.com/ https://poncesoft.blogspot.com/
Repositori dels exemples: https://github.com/xxjcaxx/sge20152016 https://github.com/xxjcaxx/SGE-Odoo-2016-2017
https://www.youtube.com/watch?v=0GUxV85DDm4&feature=youtu.be&t=5h47m45s
http://es.slideshare.net/openobject/presentation-of-the-new-openerp-api-raphael-collet-openerp
https://www.odoo.com/es_ES/slides/slide/keynote-odoo-9-new-features-201
https://media.readthedocs.org/pdf/odoo-development/latest/odoo-development.pdf
http://webkul.com/blog/beginner-guide-odoo-clicommand-line-interface/
Podcast que parlen dels beneficis d'Odoo: http://www.ivoox.com/podcast-26-odoo-transformacion-digital-audios-mp3_rf_18433975_1.html
Canal de youtube de SGE amb Odoo en castellà
https://www.odoo.yenthevg.com/extend-selection-odoo-10/
Apunts d'altres professors recopilats
https://naglis.me/post/odoo-13-changelog/ https://www.odoo.com/es_ES/forum/ayuda-1/question/odoo-13-features-and-odoo-14-expected-features-148369#answer-148370
https://medium.com/@manuelcalerosolis
https://www.youtube.com/playlist?list=PLeJtXzTubzj-tbQ94heWeQFB0twGd0vvN