Browse Source

Merge pull request #462 from blooparksystems/8.0-base_trgm_search

New addon: Fuzzy Search with PostgreSQL pg_trgm extension
pull/553/head
Daniel Reis 8 years ago
committed by GitHub
parent
commit
9d1099eedc
  1. 104
      base_search_fuzzy/README.rst
  2. 3
      base_search_fuzzy/__init__.py
  3. 20
      base_search_fuzzy/__openerp__.py
  4. 100
      base_search_fuzzy/i18n/base_search_fuzzy.pot
  5. 100
      base_search_fuzzy/i18n/de.po
  6. 4
      base_search_fuzzy/models/__init__.py
  7. 82
      base_search_fuzzy/models/ir_model.py
  8. 171
      base_search_fuzzy/models/trgm_index.py
  9. 5
      base_search_fuzzy/security/ir.model.access.csv
  10. 3
      base_search_fuzzy/tests/__init__.py
  11. 100
      base_search_fuzzy/tests/test_query_generation.py
  12. 47
      base_search_fuzzy/views/trgm_index.xml

104
base_search_fuzzy/README.rst

@ -0,0 +1,104 @@
.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
:target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
:alt: License: AGPL-3
=========================
PostgreSQL Trigram Search
=========================
This addon provides the ability to create GIN or GiST indexes of char and text
fields and also to use the search operator `%` in search domains. Currently
this module doesn't change the backend search or anything else. It provides
only the possibilty to perfrom the fuzzy search for external addons.
Installation
============
#. The PostgreSQL extension ``pg_trgm`` should be available. In debian based
distribution you have to install the `postgresql-contrib` module.
#. Install the ``pg_trgm`` extension to your database or give your postgresql
user the ``SUPERUSER`` right (this allows the odoo module to install the
extension to the database).
Configuration
=============
If the odoo module is installed:
#. You can define ``GIN`` and ``GiST`` indexes for `char` and `text` via
`Settings -> Database Structure -> Trigram Index`. The index name will
automatically created for new entries.
Usage
=====
#. You can create an index for the `name` field of `res.partner`.
#. In the search you can use:
``self.env['res.partner'].search([('name', '%', 'Jon Miller)])``
#. In this example the function will return positive result for `John Miller` or
`John Mill`.
#. You can tweak the number of strings to be returned by adjusting the set limit
(default: 0.3). NB: Currently you have to set the limit by executing the
following SQL statment:
``self.env.cr.execute("SELECT set_limit(0.2);")``
#. Another interesting feature is the use of ``similarity(column, 'text')``
function in the ``order`` parameter to order by similarity. This module just
contains a basic implementation which doesn't perform validations and has to
start with this function. For example you can define the function as
followed:
``similarity(%s.name, 'John Mil') DESC" % self.env['res.partner']._table``
For further questions read the Documentation of the
`pg_trgm <https://www.postgresql.org/docs/current/static/pgtrgm.html>`_ module.
Known issues / Roadmap
======================
* Modify the general search parts (e.g. in tree view or many2one fields)
* add better `order by` handling
Bug Tracker
===========
Bugs are tracked on `GitHub Issues
<https://github.com/OCA/server-tools/issues>`_. In case of trouble, please
check there if your issue has already been reported. If you spotted it first,
help us smashing it by providing a detailed and welcomed feedback.
Credits
=======
Images
------
* Odoo Community Association: `Icon <https://github.com/OCA/maintainer-tools/blob/master/template/module/static/description/icon.svg>`_.
Contributors
------------
* Christoph Giesel <https://github.com/christophlsa>
Maintainer
----------
.. image:: https://odoo-community.org/logo.png
:alt: Odoo Community Association
:target: https://odoo-community.org
This module is maintained by the OCA.
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
To contribute to this module, please visit https://odoo-community.org.

3
base_search_fuzzy/__init__.py

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import models

20
base_search_fuzzy/__openerp__.py

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
'name': "Fuzzy Search",
'summary': "Fuzzy search with the PostgreSQL trigram extension",
'category': 'Uncategorized',
'version': '8.0.1.0.0',
'website': 'https://odoo-community.org/',
'author': 'bloopark systems GmbH & Co. KG, '
'Odoo Community Association (OCA)',
'license': 'AGPL-3',
'depends': [
'base',
],
'data': [
'views/trgm_index.xml',
'security/ir.model.access.csv',
],
'installable': True,
}

100
base_search_fuzzy/i18n/base_search_fuzzy.pot

@ -0,0 +1,100 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * base_search_fuzzy
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 9.0alpha1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-06-24 08:47+0000\n"
"PO-Revision-Date: 2016-06-24 08:47+0000\n"
"Last-Translator: <>\n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: base_search_fuzzy
#: help:trgm.index,index_type:0
msgid "Cite from PostgreSQL documentation: \"As a rule of thumb, a GIN index is faster to search than a GiST index, but slower to build or update; so GIN is better suited for static data and GiST for often-updated data.\""
msgstr ""
#. module: base_search_fuzzy
#: field:trgm.index,create_uid:0
msgid "Created by"
msgstr ""
#. module: base_search_fuzzy
#: field:trgm.index,create_date:0
msgid "Created on"
msgstr ""
#. module: base_search_fuzzy
#: field:trgm.index,field_id:0
msgid "Field"
msgstr ""
#. module: base_search_fuzzy
#: selection:trgm.index,index_type:0
msgid "GIN"
msgstr ""
#. module: base_search_fuzzy
#: selection:trgm.index,index_type:0
msgid "GiST"
msgstr ""
#. module: base_search_fuzzy
#: field:trgm.index,id:0
msgid "ID"
msgstr ""
#. module: base_search_fuzzy
#: field:trgm.index,index_name:0
msgid "Index Name"
msgstr ""
#. module: base_search_fuzzy
#: field:trgm.index,index_type:0
msgid "Index Type"
msgstr ""
#. module: base_search_fuzzy
#: field:trgm.index,write_uid:0
msgid "Last Updated by"
msgstr ""
#. module: base_search_fuzzy
#: field:trgm.index,write_date:0
msgid "Last Updated on"
msgstr ""
#. module: base_search_fuzzy
#: model:ir.model,name:base_search_fuzzy.model_ir_model
msgid "Models"
msgstr ""
#. module: base_search_fuzzy
#: help:trgm.index,index_name:0
msgid "The index name is automatically generated like fieldname_indextype_idx. If the index already exists and the index is located in the same table then this index is resused. If the index is located in another table then a number is added at the end of the index name."
msgstr ""
#. module: base_search_fuzzy
#: model:ir.actions.act_window,name:base_search_fuzzy.trgm_index_action
#: model:ir.ui.menu,name:base_search_fuzzy.trgm_index_menu
#: view:trgm.index:base_search_fuzzy.trgm_index_view_form
#: view:trgm.index:base_search_fuzzy.trgm_index_view_tree
msgid "Trigram Index"
msgstr ""
#. module: base_search_fuzzy
#: help:trgm.index,field_id:0
msgid "You can either select a field of type \"text\" or \"char\"."
msgstr ""
#. module: base_search_fuzzy
#: code:addons/base_search_fuzzy/models/trgm_index.py:123
#, python-format
msgid "The pg_trgm extension does not exists or cannot be installed."
msgstr ""

100
base_search_fuzzy/i18n/de.po

@ -0,0 +1,100 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * base_search_fuzzy
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 9.0alpha1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-06-24 08:49+0000\n"
"PO-Revision-Date: 2016-06-24 08:49+0000\n"
"Last-Translator: <>\n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: base_search_fuzzy
#: help:trgm.index,index_type:0
msgid "Cite from PostgreSQL documentation: \"As a rule of thumb, a GIN index is faster to search than a GiST index, but slower to build or update; so GIN is better suited for static data and GiST for often-updated data.\""
msgstr "Zitat aus der PostgreSQL Dokumentation: \"Eine Fausregel ist, ein GIN Index ist schneller durchzusuchen als ein GiST Index, aber langsamer aufzubauen und zu aktualisieren; so ist GIN besser geeignet für statische Daten und GiST für oft aktualisierte Daten.\""
#. module: base_search_fuzzy
#: field:trgm.index,create_uid:0
msgid "Created by"
msgstr "Erstellt durch"
#. module: base_search_fuzzy
#: field:trgm.index,create_date:0
msgid "Created on"
msgstr "Erstellt am"
#. module: base_search_fuzzy
#: field:trgm.index,field_id:0
msgid "Field"
msgstr "Feld"
#. module: base_search_fuzzy
#: selection:trgm.index,index_type:0
msgid "GIN"
msgstr "GIN"
#. module: base_search_fuzzy
#: selection:trgm.index,index_type:0
msgid "GiST"
msgstr "GiST"
#. module: base_search_fuzzy
#: field:trgm.index,id:0
msgid "ID"
msgstr "ID"
#. module: base_search_fuzzy
#: field:trgm.index,index_name:0
msgid "Index Name"
msgstr "Index Name"
#. module: base_search_fuzzy
#: field:trgm.index,index_type:0
msgid "Index Type"
msgstr "Index Typ"
#. module: base_search_fuzzy
#: field:trgm.index,write_uid:0
msgid "Last Updated by"
msgstr "Zuletzt aktualisiert durch"
#. module: base_search_fuzzy
#: field:trgm.index,write_date:0
msgid "Last Updated on"
msgstr "Zuletzt aktualisiert am"
#. module: base_search_fuzzy
#: model:ir.model,name:base_search_fuzzy.model_ir_model
msgid "Models"
msgstr "Datenmodelle"
#. module: base_search_fuzzy
#: help:trgm.index,index_name:0
msgid "The index name is automatically generated like fieldname_indextype_idx. If the index already exists and the index is located in the same table then this index is resused. If the index is located in another table then a number is added at the end of the index name."
msgstr "Der Index Name wird automatisch im Format feldname_indextyp_idx generiert. Falls der Index bereits existiert und der Index sich in der selben Tabelle befindet, dann wird dieser Index wiederverwendet. Falls der Index in einer anderen Tabelle existiert, dann wird eine Zahl an das Ende des Index Namens angefügt."
#. module: base_search_fuzzy
#: model:ir.actions.act_window,name:base_search_fuzzy.trgm_index_action
#: model:ir.ui.menu,name:base_search_fuzzy.trgm_index_menu
#: view:trgm.index:base_search_fuzzy.trgm_index_view_form
#: view:trgm.index:base_search_fuzzy.trgm_index_view_tree
msgid "Trigram Index"
msgstr "Trigram Index"
#. module: base_search_fuzzy
#: help:trgm.index,field_id:0
msgid "You can either select a field of type \"text\" or \"char\"."
msgstr "Sie können entweder Felder vom Typ \"text\" oder \"char\" auswählen."
#. module: base_search_fuzzy
#: code:addons/base_search_fuzzy/models/trgm_index.py:123
#, python-format
msgid "The pg_trgm extension does not exists or cannot be installed."
msgstr "Die pg_trgm Erweiterung existiert nicht oder kann nicht installiert werden."

4
base_search_fuzzy/models/__init__.py

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import ir_model
from . import trgm_index

82
base_search_fuzzy/models/ir_model.py

@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from openerp import models
from openerp.osv import expression
_logger = logging.getLogger(__name__)
def patch_leaf_trgm(method):
def decorate_leaf_to_sql(self, eleaf):
model = eleaf.model
leaf = eleaf.leaf
left, operator, right = leaf
table_alias = '"%s"' % (eleaf.generate_alias())
if operator == '%':
sql_operator = '%%'
params = []
if left in model._columns:
format = model._columns[left]._symbol_set[0]
column = '%s.%s' % (table_alias, expression._quote(left))
query = '(%s %s %s)' % (column, sql_operator, format)
elif left in expression.MAGIC_COLUMNS:
query = "(%s.\"%s\" %s %%s)" % (
table_alias, left, sql_operator)
params = right
else: # Must not happen
raise ValueError(
"Invalid field %r in domain term %r" % (left, leaf))
if left in model._columns:
params = model._columns[left]._symbol_set[1](right)
if isinstance(params, basestring):
params = [params]
return query, params
elif operator == 'inselect':
right = (right[0].replace(' % ', ' %% '), right[1])
eleaf.leaf = (left, operator, right)
return method(self, eleaf)
decorate_leaf_to_sql.__decorated__ = True
return decorate_leaf_to_sql
def patch_generate_order_by(method):
def decorate_generate_order_by(self, order_spec, query):
if order_spec and order_spec.startswith('similarity('):
return ' ORDER BY ' + order_spec
return method(self, order_spec, query)
decorate_generate_order_by.__decorated__ = True
return decorate_generate_order_by
class IrModel(models.Model):
_inherit = 'ir.model'
def _register_hook(self, cr, ids=None):
# We have to prevent wrapping the function twice to avoid recursion
# errors
if not hasattr(expression.expression._expression__leaf_to_sql,
'__decorated__'):
expression.expression._expression__leaf_to_sql = patch_leaf_trgm(
expression.expression._expression__leaf_to_sql)
if '%' not in expression.TERM_OPERATORS:
expression.TERM_OPERATORS += ('%',)
if not hasattr(models.BaseModel._generate_order_by,
'__decorated__'):
models.BaseModel._generate_order_by = patch_generate_order_by(
models.BaseModel._generate_order_by)
return super(IrModel, self)._register_hook(cr)

171
base_search_fuzzy/models/trgm_index.py

@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from openerp import SUPERUSER_ID, _, api, exceptions, fields, models
from psycopg2.extensions import AsIs
_logger = logging.getLogger(__name__)
class TrgmIndex(models.Model):
"""Model for Trigram Index."""
_name = 'trgm.index'
_rec_name = 'field_id'
field_id = fields.Many2one(
comodel_name='ir.model.fields',
string='Field',
required=True,
help='You can either select a field of type "text" or "char".'
)
index_name = fields.Char(
string='Index Name',
readonly=True,
help='The index name is automatically generated like '
'fieldname_indextype_idx. If the index already exists and the '
'index is located in the same table then this index is resused. '
'If the index is located in another table then a number is added '
'at the end of the index name.'
)
index_type = fields.Selection(
selection=[('gin', 'GIN'), ('gist', 'GiST')],
string='Index Type',
default='gin',
required=True,
help='Cite from PostgreSQL documentation: "As a rule of thumb, a '
'GIN index is faster to search than a GiST index, but slower to '
'build or update; so GIN is better suited for static data and '
'GiST for often-updated data."'
)
@api.model
def _trgm_extension_exists(self):
self.env.cr.execute("""
SELECT name, installed_version
FROM pg_available_extensions
WHERE name = 'pg_trgm'
LIMIT 1;
""")
extension = self.env.cr.fetchone()
if extension is None:
return 'missing'
if extension[1] is None:
return 'uninstalled'
return 'installed'
@api.model
def _is_postgres_superuser(self):
self.env.cr.execute("SHOW is_superuser;")
superuser = self.env.cr.fetchone()
return superuser is not None and superuser[0] == 'on' or False
@api.model
def _install_trgm_extension(self):
extension = self._trgm_extension_exists()
if extension == 'missing':
_logger.warning('To use pg_trgm you have to install the '
'postgres-contrib module.')
elif extension == 'uninstalled':
if self._is_postgres_superuser():
self.env.cr.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
return True
else:
_logger.warning('To use pg_trgm you have to create the '
'extension pg_trgm in your database or you '
'have to be the superuser.')
else:
return True
return False
def _auto_init(self, cr, context=None):
res = super(TrgmIndex, self)._auto_init(cr, context)
if self._install_trgm_extension(cr, SUPERUSER_ID, context=context):
_logger.info('The pg_trgm is loaded in the database and the '
'fuzzy search can be used.')
return res
@api.model
def get_not_used_index(self, index_name, table_name, inc=1):
if inc > 1:
new_index_name = index_name + str(inc)
else:
new_index_name = index_name
self.env.cr.execute("""
SELECT tablename, indexname
FROM pg_indexes
WHERE indexname = %(index)s;
""", {'index': new_index_name})
indexes = self.env.cr.fetchone()
if indexes is not None and indexes[0] == table_name:
return True, index_name
elif indexes is not None:
return self.get_not_used_index(index_name, table_name,
inc + 1)
return False, new_index_name
@api.multi
def create_index(self):
self.ensure_one()
if not self._install_trgm_extension():
raise exceptions.UserError(_(
'The pg_trgm extension does not exists or cannot be '
'installed.'))
table_name = self.env[self.field_id.model_id.model]._table
column_name = self.field_id.name
index_type = self.index_type
index_name = '%s_%s_idx' % (column_name, index_type)
index_exists, index_name = self.get_not_used_index(
index_name, table_name)
if not index_exists:
self.env.cr.execute("""
CREATE INDEX %(index)s
ON %(table)s
USING %(indextype)s (%(column)s %(indextype)s_trgm_ops);
""", {
'table': AsIs(table_name),
'index': AsIs(index_name),
'column': AsIs(column_name),
'indextype': AsIs(index_type)
})
return index_name
@api.model
def index_exists(self, model_name, field_name):
field = self.env['ir.model.fields'].search([
('model', '=', model_name), ('name', '=', field_name)], limit=1)
if not field:
return False
trgm_index = self.search([('field_id', '=', field.id)], limit=1)
return bool(trgm_index)
@api.model
def create(self, vals):
rec = super(TrgmIndex, self).create(vals)
rec.index_name = rec.create_index()
return rec
@api.multi
def unlink(self):
for rec in self:
self.env.cr.execute("""
DROP INDEX IF EXISTS %(index)s;
""", {
'index': AsIs(rec.index_name),
})
return super(TrgmIndex, self).unlink()

5
base_search_fuzzy/security/ir.model.access.csv

@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_trgm_index_public,trgm_index group_public,model_trgm_index,base.group_public,1,0,0,0
access_trgm_index_portal,trgm_index group_portal,model_trgm_index,base.group_portal,1,0,0,0
access_trgm_index_group_partner_manager,trgm_index group_partner_manager,model_trgm_index,base.group_no_one,1,1,1,1
access_trgm_index_group_user,trgm_index group_user,model_trgm_index,base.group_user,1,0,0,0

3
base_search_fuzzy/tests/__init__.py

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import test_query_generation

100
base_search_fuzzy/tests/test_query_generation.py

@ -0,0 +1,100 @@
# -*- coding: utf-8 -*-
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from openerp.osv import expression
from openerp.tests.common import TransactionCase, at_install, post_install
@at_install(False)
@post_install(True)
class QueryGenerationCase(TransactionCase):
def setUp(self):
super(QueryGenerationCase, self).setUp()
self.ResPartner = self.env['res.partner']
self.TrgmIndex = self.env['trgm.index']
self.ResPartnerCategory = self.env['res.partner.category']
def test_fuzzy_where_generation(self):
"""Check the generation of the where clause."""
# the added fuzzy search operator should be available in the allowed
# operators
self.assertIn('%', expression.TERM_OPERATORS)
# create new query with fuzzy search operator
query = self.ResPartner._where_calc(
[('name', '%', 'test')], active_test=False)
from_clause, where_clause, where_clause_params = query.get_sql()
# the % parameter has to be escaped (%%) for the string replation
self.assertEqual(where_clause, """("res_partner"."name" %% %s)""")
# test the right sql query statement creation
# now there should be only one '%'
complete_where = self.env.cr.mogrify(
"SELECT FROM %s WHERE %s" % (from_clause, where_clause),
where_clause_params)
self.assertEqual(
complete_where,
'SELECT FROM "res_partner" WHERE '
'("res_partner"."name" % \'test\')')
def test_fuzzy_where_generation_translatable(self):
"""Check the generation of the where clause for translatable fields."""
ctx = {'lang': 'de_DE'}
# create new query with fuzzy search operator
query = self.ResPartnerCategory.with_context(ctx)\
._where_calc([('name', '%', 'Goschaeftlic')], active_test=False)
from_clause, where_clause, where_clause_params = query.get_sql()
# the % parameter has to be escaped (%%) for the string replation
self.assertIn("""SELECT id FROM temp_irt_current WHERE name %% %s""",
where_clause)
complete_where = self.env.cr.mogrify(
"SELECT FROM %s WHERE %s" % (from_clause, where_clause),
where_clause_params)
self.assertIn(
"""SELECT id FROM temp_irt_current WHERE name % 'Goschaeftlic'""",
complete_where)
def test_fuzzy_order_generation(self):
"""Check the generation of the where clause."""
order = "similarity(%s.name, 'test') DESC" % self.ResPartner._table
query = self.ResPartner._where_calc(
[('name', '%', 'test')], active_test=False)
order_by = self.ResPartner._generate_order_by(order, query)
self.assertEqual(' ORDER BY %s' % order, order_by)
def test_fuzzy_search(self):
"""Test the fuzzy search itself."""
if self.TrgmIndex._trgm_extension_exists() != 'installed':
return
if not self.TrgmIndex.index_exists('res.partner', 'name'):
field_partner_name = self.env.ref('base.field_res_partner_name')
self.TrgmIndex.create({
'field_id': field_partner_name.id,
'index_type': 'gin',
})
partner1 = self.ResPartner.create({
'name': 'John Smith'
})
partner2 = self.ResPartner.create(
{'name': 'John Smizz'}
)
partner3 = self.ResPartner.create({
'name': 'Linus Torvalds'
})
res = self.ResPartner.search([('name', '%', 'Jon Smith')])
self.assertIn(partner1.id, res.ids)
self.assertIn(partner2.id, res.ids)
self.assertNotIn(partner3.id, res.ids)
res = self.ResPartner.search([('name', '%', 'Smith John')])
self.assertIn(partner1.id, res.ids)
self.assertIn(partner2.id, res.ids)
self.assertNotIn(partner3.id, res.ids)

47
base_search_fuzzy/views/trgm_index.xml

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<openerp>
<data>
<record model="ir.ui.view" id="trgm_index_view_form">
<field name="name">trgm.index.view.form</field>
<field name="model">trgm.index</field>
<field name="arch" type="xml">
<form string="Trigam Index">
<sheet>
<group col="4">
<field name="field_id" domain="[('ttype', 'in', ['char', 'text'])]"/>
<field name="index_name"/>
<field name="index_type"/>
</group>
</sheet>
</form>
</field>
</record>
<record model="ir.ui.view" id="trgm_index_view_tree">
<field name="name">trgm.index.view.tree</field>
<field name="model">trgm.index</field>
<field name="arch" type="xml">
<tree string="Trigam Index">
<field name="field_id"/>
<field name="index_name"/>
<field name="index_type"/>
</tree>
</field>
</record>
<record model="ir.actions.act_window" id="trgm_index_action">
<field name="name">Trigram Index</field>
<field name="res_model">trgm.index</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="type">ir.actions.act_window</field>
</record>
<menuitem id="trgm_index_menu"
parent="base.next_id_9"
action="trgm_index_action"
groups="base.group_no_one"/>
</data>
</openerp>
Loading…
Cancel
Save