Browse Source

Add possibility to delete attachments

12.0-mig-module_prototyper_last
Florian da Costa 5 years ago
parent
commit
414312b519
  1. 66
      autovacuum_message_attachment/README.rst
  2. 6
      autovacuum_message_attachment/__manifest__.py
  3. 15
      autovacuum_message_attachment/data/data.xml
  4. 4
      autovacuum_message_attachment/models/__init__.py
  5. 45
      autovacuum_message_attachment/models/autovacuum_mixin.py
  6. 9
      autovacuum_message_attachment/models/ir_attachment.py
  7. 42
      autovacuum_message_attachment/models/mail_message.py
  8. 79
      autovacuum_message_attachment/models/vacuum_rule.py
  9. 6
      autovacuum_message_attachment/readme/CONFIGURE.rst
  10. 1
      autovacuum_message_attachment/readme/DESCRIPTION.rst
  11. 2
      autovacuum_message_attachment/readme/ROADMAP.rst
  12. 4
      autovacuum_message_attachment/security/ir.model.access.csv
  13. 3
      autovacuum_message_attachment/tests/__init__.py
  14. 85
      autovacuum_message_attachment/tests/test_vacuum_rule.py
  15. 37
      autovacuum_message_attachment/views/rule_vacuum.xml

66
autovacuum_message_attachment/README.rst

@ -1,66 +0,0 @@
.. image:: https://img.shields.io/badge/license-AGPL--3-blue.png
:alt: License: LGPL-3
=======================
AutoVacuum Mail Message
=======================
Odoo create a lot of message and/or mails. With time it can slow the system or take a lot of disk space.
The goal of this module is to clean these message once they are obsolete.
Configuration
=============
* Go to the menu configuration => Technical => Email => Message vacuum Rule
* Add the adequates rules for your company. On each rule, you can indicate the models, type and subtypes for which you want to delete the messages, along with a retention time (in days).
* Activate the cron AutoVacuum Mails and Messages
It is recommanded to run it frequently and when the system is not very loaded.
(For instance : once a day, during the night.)
Usage
=====
.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas
:alt: Try me on Runbot
:target: https://runbot.odoo-community.org/runbot/149/9.0
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 smash it by providing detailed and welcomed feedback.
Credits
=======
Images
------
* Odoo Community Association: `Icon <https://odoo-community.org/logo.png>`_.
Contributors
------------
* Florian da Costa <florian.dacosta@akretion.com>
Do not contact contributors directly about support or help with technical issues.
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.

6
autovacuum_message_attachment/__manifest__.py

@ -2,20 +2,20 @@
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
{
"name": "AutoVacuum Mail Message",
"name": "AutoVacuum Mail Message and Attachment",
"version": "12.0.1.0.0",
"category": "Tools",
"website": "https://github.com/OCA/server-tools",
"author": "Akretion, Odoo Community Association (OCA)",
"license": "LGPL-3",
"installable": True,
"summary": "Automatically delete old mail messages to clean database",
"summary": "Automatically delete old mail messages and attachments",
"depends": [
"mail",
],
"data": [
"data/data.xml",
"views/message_rule_vacuum.xml",
"views/rule_vacuum.xml",
"security/ir.model.access.csv",
],
}

15
autovacuum_message_attachment/data/data.xml

@ -10,9 +10,22 @@
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="state">code</field>
<field name="code">model.autovacuum_mail_message()</field>
<field name="code">model.autovacuum('message')</field>
<field eval="False" name="doall"/>
<field name="model_id" ref="mail.model_mail_message"/>
</record>
<record id="ir_cron_vacuum_attachment" model="ir.cron">
<field name="name">AutoVacuum Attachments</field>
<field eval="False" name="active"/>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="state">code</field>
<field name="code">model.autovacuum('attachment')</field>
<field eval="False" name="doall"/>
<field name="model_id" ref="base.model_ir_attachment"/>
</record>
</odoo>

4
autovacuum_message_attachment/models/__init__.py

@ -1,2 +1,4 @@
from . import autovacuum_mixin
from . import ir_attachment
from . import mail_message
from . import message_vacuum_rule
from . import vacuum_rule

45
autovacuum_message_attachment/models/autovacuum_mixin.py

@ -0,0 +1,45 @@
# Copyright (C) 2019 Akretion
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
import logging
import odoo
from odoo import api, models
_logger = logging.getLogger(__name__)
class AutovacuumMixin(models.AbstractModel):
_name = "autovacuum.mixin"
_description = "Mixin used to delete messages or attachments"
@api.multi
def batch_unlink(self):
with api.Environment.manage():
with odoo.registry(
self.env.cr.dbname).cursor() as new_cr:
new_env = api.Environment(new_cr, self.env.uid,
self.env.context)
try:
while self:
batch_delete = self[0:1000]
self -= batch_delete
# do not attach new env to self because it may be
# huge, and the cache is cleaned after each unlink
# so we do not want to much record is the env in
# which we call unlink because odoo would prefetch
# fields, cleared right after.
batch_delete.with_env(new_env).unlink()
new_env.cr.commit()
except Exception as e:
_logger.exception(
"Failed to delete Ms : %s" % (self._name, str(e)))
# Call by cron
@api.model
def autovacuum(self, ttype='message'):
rules = self.env['vacuum.rule'].search([('ttype', '=', ttype)])
for rule in rules:
domain = rule.get_domain()
records = self.search(domain)
records.batch_unlink()

9
autovacuum_message_attachment/models/ir_attachment.py

@ -0,0 +1,9 @@
# Copyright (C) 2018 Akretion
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
from odoo import models
class IrAttachment(models.Model):
_name = "ir.attachment"
_inherit = ["ir.attachment", "autovacuum.mixin"]

42
autovacuum_message_attachment/models/mail_message.py

@ -1,44 +1,10 @@
# -*- coding: utf-8 -*-
# Copyright (C) 2018 Akretion
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html).
import logging
import odoo
from odoo import api, models
_logger = logging.getLogger(__name__)
from odoo import models
class MailMessage(models.Model):
_inherit = "mail.message"
@api.multi
def batch_unlink(self):
with api.Environment.manage():
with odoo.registry(
self.env.cr.dbname).cursor() as new_cr:
new_env = api.Environment(new_cr, self.env.uid,
self.env.context)
try:
while self:
batch_delete_messages = self[0:1000]
self -= batch_delete_messages
# do not attach new env to self because it may be
# huge, and the cache is cleaned after each unlink
# so we do not want to much record is the env in
# which we call unlink because odoo would prefetch
# fields, cleared right after.
batch_delete_messages.with_env(new_env).unlink()
new_env.cr.commit()
except Exception as e:
_logger.exception(
"Failed to delete messages : %s", str(e))
# Call by cron
@api.model
def autovacuum_mail_message(self):
rules = self.env['message.vacuum.rule'].search([])
for rule in rules:
domain = rule.get_message_domain()
messages = self.search(domain)
messages.batch_unlink()
_name = "mail.message"
_inherit = ["mail.message", "autovacuum.mixin"]

79
autovacuum_message_attachment/models/message_vacuum_rule.py → autovacuum_message_attachment/models/vacuum_rule.py

@ -9,13 +9,13 @@ from odoo.tools.safe_eval import safe_eval
import datetime
class MessageVacuumRule(models.Model):
_name = "message.vacuum.rule"
class VacuumRule(models.Model):
_name = "vacuum.rule"
_description = "Rules Used to delete message historic"
@api.depends('model_ids')
@api.multi
def _compute_model_id(self):
def _get_model_id(self):
for rule in self:
if rule.model_ids and len(rule.model_ids) == 1:
rule.model_id = rule.model_ids.id
@ -23,10 +23,18 @@ class MessageVacuumRule(models.Model):
rule.model_id = False
name = fields.Char(required=True)
ttype = fields.Selection(
selection=[('attachment', 'Attachment'),
('message', 'Message')],
string="Type",
required=True)
filename_pattern = fields.Char(
help=("If set, only attachments containing this pattern will be"
" deleted."))
company_id = fields.Many2one(
'res.company', string="Company",
default=lambda self: self.env['res.company']._company_default_get(
'message.vacuum.rule'))
'vacuum.rule'))
message_subtype_ids = fields.Many2many(
'mail.message.subtype', string="Subtypes",
help="Message subtypes concerned by the rule. If left empty, the "
@ -40,7 +48,7 @@ class MessageVacuumRule(models.Model):
"models into account")
model_id = fields.Many2one(
'ir.model', readonly=True,
compute='_compute_model_id',
compute='_get_model_id',
help="Technical field used to set attributes (invisible/required, "
"domain, etc...for other fields, like the domain filter")
model_filter_domain = fields.Text(
@ -49,7 +57,7 @@ class MessageVacuumRule(models.Model):
('email', 'Email'),
('comment', 'Comment'),
('notification', 'System notification'),
('all', 'All')], required=True)
('all', 'All')])
retention_time = fields.Integer(
required=True, default=365,
help="Number of days the messages concerned by this rule will be "
@ -67,11 +75,10 @@ class MessageVacuumRule(models.Model):
_("The Retention Time can't be 0 days"))
@api.multi
def get_message_domain(self):
self.ensure_one()
def _get_message_domain(self):
today = date.today()
limit_date = today - timedelta(days=self.retention_time)
limit_date = limit_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
limit_date = (today - timedelta(days=self.retention_time)).strftime(
DEFAULT_SERVER_DATE_FORMAT)
message_domain = [('date', '<', limit_date)]
if self.message_type != 'all':
message_domain += [('message_type', '=', self.message_type)]
@ -80,24 +87,48 @@ class MessageVacuumRule(models.Model):
message_domain += [('model', 'in', models)]
subtype_ids = self.message_subtype_ids.ids
subtype_domain = []
if subtype_ids and self.empty_subtype:
subtype_domain = ['|', ('subtype_id', 'in', subtype_ids),
message_domain = ['|', ('subtype_id', 'in', subtype_ids),
('subtype_id', '=', False)]
elif subtype_ids and not self.empty_subtype:
subtype_domain += [('subtype_id', 'in', subtype_ids)]
message_domain += [('subtype_id', 'in', subtype_ids)]
elif not subtype_ids and not self.empty_subtype:
subtype_domain += [('subtype_id', '!=', False)]
message_domain += subtype_domain
message_domain += [('subtype_id', '!=', False)]
return message_domain
@api.multi
def _get_attachment_domain(self):
today = date.today()
limit_date = (today - timedelta(days=self.retention_time)).strftime(
DEFAULT_SERVER_DATE_FORMAT)
attachment_domain = [('create_date', '<', limit_date)]
if self.filename_pattern:
attachment_domain += [('name', 'ilike', self.filename_pattern)]
if self.model_ids:
models = self.model_ids.mapped('model')
attachment_domain += [('res_model', 'in', models)]
else:
# Avoid deleting attachment without model, if there are, it is
# probably some attachments created by Odoo
attachment_domain += [('res_model', '!=', False)]
return attachment_domain
@api.multi
def get_domain(self):
self.ensure_one()
domain = []
if self.ttype == 'message':
domain += self._get_message_domain()
elif self.ttype == 'attachment':
domain += self._get_attachment_domain()
# Case we want a condition on linked model records
if self.model_id and self.model_filter_domain:
domain = safe_eval(self.model_filter_domain,
locals_dict={'datetime': datetime})
record_domain = safe_eval(self.model_filter_domain,
locals_dict={'datetime': datetime})
res_model = self.model_id.model
res_records = self.env[res_model].with_context(
active_test=False).search(domain)
res_ids = res_records.ids
message_domain += ['|', ('res_id', 'in', res_ids),
('res_id', '=', False)]
return message_domain
res_ids = self.env[self.model_id.model].with_context(
active_test=False).search(record_domain).ids
domain += ['|', ('res_id', 'in', res_ids),
('res_id', '=', False)]
return domain

6
autovacuum_message_attachment/readme/CONFIGURE.rst

@ -1,6 +1,6 @@
* Go to the menu configuration => Technical => Email => Message Vacuum Rules
* Add the adequates rules for your company. On each rule, you can indicate the models, type and subtypes for which you want to delete the messages, along with a retention time (in days).
* Activate the cron AutoVacuum Mails and Messages
* Go to the menu configuration => Technical => Email => Message And Attachment Vacuum Rules
* Add the adequates rules for your company. On each rule, you can indicate the models, type and subtypes for which you want to delete the messages, along with a retention time (in days). Or for attachment, you can specify a substring of the name.
* Activate the cron AutoVacuum Mails and Messages and/or AutoVacuum Attachments
It is recommanded to run it frequently and when the system is not very loaded.
(For instance : once a day, during the night.)

1
autovacuum_message_attachment/readme/DESCRIPTION.rst

@ -1,3 +1,4 @@
Odoo create a lot of message and/or mails. With time it can slow the system or take a lot of disk space.
The goal of this module is to clean these message once they are obsolete.
The same may happen with attachment that we store.
You can choose various criterias manage which messages you want to delete automatically.

2
autovacuum_message_attachment/readme/ROADMAP.rst

@ -0,0 +1,2 @@
You have to be careful with rules regarding attachment deletion because Odoo find the attachment to delete with their name.
Odoo will find all attachments containing the substring configured on the rule, so you have to be specific enough on the other criterias (concerned models...) to avoid unwanted attachment deletion.

4
autovacuum_message_attachment/security/ir.model.access.csv

@ -1,2 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_full_message_vaccum_rule,access.full.message.vaccum.rule,model_message_vacuum_rule,base.group_system,1,1,1,1
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_full_vaccum_rule,access.full.vaccum.rule,model_vacuum_rule,base.group_system,1,1,1,1

3
autovacuum_message_attachment/tests/__init__.py

@ -1,4 +1,3 @@
# © 2018 Akretion (Florian da Costa)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from . import test_message_vacuum_rule
from . import test_vacuum_rule

85
autovacuum_message_attachment/tests/test_message_vacuum_rule.py → autovacuum_message_attachment/tests/test_vacuum_rule.py

@ -5,17 +5,19 @@ from datetime import date, timedelta
from odoo import api, exceptions
from odoo.tests import common
from odoo.tools import DEFAULT_SERVER_DATE_FORMAT
import base64
class TestMessageVacuumRule(common.TransactionCase):
class TestVacuumRule(common.TransactionCase):
def create_mail_message(self, message_type, subtype):
vals = {
'message_type': message_type,
'subtype_id': subtype and subtype.id or False,
'date': self.before_400_days,
'model': 'mail.channel',
'res_id': self.env.ref('mail.channel_all_employees').id,
'model': 'res.partner',
'res_id': self.env.ref('base.partner_root').id,
'subject': 'Test',
'body': 'Body Test',
}
@ -23,49 +25,51 @@ class TestMessageVacuumRule(common.TransactionCase):
def tearDown(self):
self.registry.leave_test_mode()
super(TestMessageVacuumRule, self).tearDown()
super(TestVacuumRule, self).tearDown()
def setUp(self):
super(TestMessageVacuumRule, self).setUp()
super(TestVacuumRule, self).setUp()
self.registry.enter_test_mode(self.env.cr)
self.env = api.Environment(self.registry.test_cr, self.env.uid,
self.env.context)
self.subtype = self.env.ref('mail.mt_comment')
self.message_obj = self.env['mail.message']
self.channel_model = self.env.ref('mail.model_mail_channel')
self.attachment_obj = self.env['ir.attachment']
self.partner_model = self.env.ref('base.model_res_partner')
today = date.today()
self.before_400_days = today - timedelta(days=400)
def test_mail_vacuum_rules(self):
rule_vals = {
'name': 'Subtype Model',
'ttype': 'message',
'retention_time': 399,
'message_type': 'email',
'model_ids': [(6, 0, [self.channel_model.id])],
'model_ids': [(6, 0, [self.env.ref('base.model_res_partner').id])],
'message_subtype_ids': [(6, 0, [self.subtype.id])],
}
rule = self.env['message.vacuum.rule'].create(rule_vals)
rule = self.env['vacuum.rule'].create(rule_vals)
m1 = self.create_mail_message('notification', self.subtype)
m2 = self.create_mail_message('email', self.env.ref('mail.mt_note'))
m3 = self.create_mail_message('email', False)
message_ids = [m1.id, m2.id, m3.id]
self.message_obj.autovacuum_mail_message()
self.message_obj.autovacuum(ttype='message')
message = self.message_obj.search(
[('id', 'in', message_ids)])
# no message deleted because either message_type is wrong or subtype
# is wront or subtype is empty
# is wrong or subtype is empty
self.assertEqual(len(message),
3)
rule.write({'message_type': 'notification', 'retention_time': 405})
self.message_obj.autovacuum_mail_message()
self.message_obj.autovacuum(ttype='message')
message = self.message_obj.search(
[('id', 'in', message_ids)])
# no message deleted because of retention time
self.assertEqual(len(message),
3)
rule.write({'retention_time': 399})
self.message_obj.autovacuum_mail_message()
self.message_obj.autovacuum(ttype='message')
message = self.message_obj.search(
[('id', 'in', message_ids)])
@ -75,20 +79,66 @@ class TestMessageVacuumRule(common.TransactionCase):
rule.write({'message_type': 'email',
'message_subtype_ids': [(6, 0, [])],
'empty_subtype': True})
self.message_obj.autovacuum_mail_message()
self.message_obj.autovacuum(ttype='message')
message = self.message_obj.search(
[('id', 'in', message_ids)])
self.assertEqual(len(message),
0)
def create_attachment(self, name):
vals = {
'name': name,
'datas': base64.b64encode(b'Content'),
'datas_fname': name,
'res_id': self.env.ref('base.partner_root').id,
'res_model': 'res.partner',
}
return self.env['ir.attachment'].create(vals)
def test_attachment_vacuum_rule(self):
rule_vals = {
'name': 'Partner Attachments',
'ttype': 'attachment',
'retention_time': 100,
'model_ids': [(6, 0, [self.partner_model.id])],
'filename_pattern': 'test',
}
self.env['vacuum.rule'].create(rule_vals)
a1 = self.create_attachment('Test-dummy')
a2 = self.create_attachment('test24')
# Force create date to old date to test deletion with 100 days
# retention time
before_102_days = date.today() - timedelta(days=102)
before_102_days_str = before_102_days.strftime(
DEFAULT_SERVER_DATE_FORMAT)
self.env.cr.execute("""
UPDATE ir_attachment SET create_date = '%s'
WHERE id = %s
""" % (before_102_days_str, a2.id))
a2.write({'create_date': date.today() - timedelta(days=102)})
a3 = self.create_attachment('other')
self.env.cr.execute("""
UPDATE ir_attachment SET create_date = '%s'
WHERE id = %s
""" % (before_102_days_str, a3.id))
attachment_ids = [a1.id, a2.id, a3.id]
self.attachment_obj.autovacuum(ttype='attachment')
attachments = self.attachment_obj.search(
[('id', 'in', attachment_ids)])
# Only one message deleted because other 2 are with bad name or to
# recent.
self.assertEqual(len(attachments),
2)
def test_retention_time_constraint(self):
rule_vals = {
'name': 'Subtype Model',
'ttype': 'message',
'retention_time': 0,
'message_type': 'email',
}
with self.assertRaises(exceptions.ValidationError):
self.env['message.vacuum.rule'].create(rule_vals)
self.env['vacuum.rule'].create(rule_vals)
def test_res_model_domain(self):
partner = self.env['res.partner'].create({'name': 'Test Partner'})
@ -100,19 +150,20 @@ class TestMessageVacuumRule(common.TransactionCase):
rule_vals = {
'name': 'Partners',
'ttype': 'message',
'retention_time': 399,
'message_type': 'all',
'model_ids': [(6, 0, [partner_model.id])],
'model_filter_domain': "[['name', '=', 'Dummy']]",
'empty_subtype': True,
}
rule = self.env['message.vacuum.rule'].create(rule_vals)
self.message_obj.autovacuum_mail_message()
rule = self.env['vacuum.rule'].create(rule_vals)
self.message_obj.autovacuum(ttype='message')
# no message deleted as the filter does not match
self.assertEqual(len(partner.message_ids), 1)
rule.write({
'model_filter_domain': "[['name', '=', 'Test Partner']]"
})
self.message_obj.autovacuum_mail_message()
self.message_obj.autovacuum(ttype='message')
self.assertEqual(len(partner.message_ids), 0)

37
autovacuum_message_attachment/views/message_rule_vacuum.xml → autovacuum_message_attachment/views/rule_vacuum.xml

@ -2,27 +2,34 @@
<odoo>
<record model="ir.ui.view" id="message_vacuum_rule_form_view">
<field name="name">message.vacuum.rule.form.view</field>
<field name="model">message.vacuum.rule</field>
<record model="ir.ui.view" id="vacuum_rule_form_view">
<field name="name">vacuum.rule.form.view</field>
<field name="model">vacuum.rule</field>
<field name="arch" type="xml">
<form string="Message Vacuum Rule">
<sheet>
<group col="4">
<group col="4" colspan="4">
<field name="name" colspan="2"/>
<field name="ttype" colspan="2"/>
<field name="company_id" colspan="2"/>
<field name="message_type" colspan="2"/>
<field name="empty_subtype" colspan="2"/>
<field name="retention_time" colspan="2"/>
<field name="active" colspan="2"/>
</group>
<group col="4" colspan="4" attrs="{'invisible': [('ttype', '!=', 'message')]}">
<field name="message_type" attrs="{'required': [('ttype', '=', 'message')]}" colspan="2"/>
<field name="empty_subtype" colspan="2"/>
<separator string="Message Subtypes" colspan="4"/>
<field name="message_subtype_ids" nolabel="1" colspan="4"/>
</group>
<group col="4" colspan="4" attrs="{'invisible': [('ttype', '!=', 'attachment')]}">
<field name="filename_pattern" colspan="2"/>
</group>
<separator string="Message Models" colspan="4"/>
<field name="model_ids" nolabel="1" colspan="4"/>
<field name="model_id" colspan="4"/>
<field name="model_filter_domain" attrs="{'invisible': [('model_id', '=', False)]}" colspan="4"/>
<separator string="Message Subtypes" colspan="4"/>
<field name="message_subtype_ids" nolabel="1" colspan="4"/>
<separator string="Description" colspan="4"/>
<field name="description" nolabel="1" colspan="4"/>
</group>
@ -31,27 +38,25 @@
</field>
</record>
<record model="ir.ui.view" id="message_vacuum_rule_tree_view">
<field name="name">message.vacuum.rule.form.view</field>
<field name="model">message.vacuum.rule</field>
<record model="ir.ui.view" id="vacuum_rule_tree_view">
<field name="name">vacuum.rule.form.view</field>
<field name="model">vacuum.rule</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="company_id"/>
<field name="message_type"/>
<field name="empty_subtype"/>
<field name="retention_time"/>
</tree>
</field>
</record>
<record model="ir.actions.act_window" id="action_message_vacuum_rule">
<field name="name">Message Vacuum Rules</field>
<field name="res_model">message.vacuum.rule</field>
<record model="ir.actions.act_window" id="action_vacuum_rule">
<field name="name">Message and Attachment Vacuum Rule</field>
<field name="res_model">vacuum.rule</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
</record>
<menuitem id="menu_action_message_vacuum_rule" parent="base.menu_email" action="action_message_vacuum_rule"/>
<menuitem id="menu_action_vacuum_rule" parent="base.menu_email" action="action_vacuum_rule"/>
</odoo>
Loading…
Cancel
Save