Browse Source

[PORT] [9.0] record_archiver

pull/1381/head
Yannick Vaucher 9 years ago
committed by Artem Kostyuk
parent
commit
5bb549d9b4
  1. 71
      record_archiver/README.rst
  2. 88
      record_archiver/__openerp__.py
  3. 90
      record_archiver/models/ir_model.py
  4. 123
      record_archiver/models/record_lifespan.py
  5. 6
      record_archiver/tests/__init__.py
  6. 70
      record_archiver/tests/test_active_search.py
  7. 74
      record_archiver/tests/test_archive.py
  8. 8
      record_archiver/views/record_lifespan_view.xml

71
record_archiver/README.rst

@ -0,0 +1,71 @@
.. 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
================
Records Archiver
================
Create a cron job that deactivates old records in order to optimize
performance.
Records are deactivated based on their last activity (write_date).
Configuration
=============
You can configure lifespan of each type of record in
`Settings -> Configuration -> Records Archiver`
A different lifespan can be configured for each model.
Usage
=====
Once the lifespans are configured, the cron will automatically
deactivate the old records.
Known issues / Roadmap
======================
The default behavior is to archive all records having a ``write_date`` <
lifespan and with a state being ``done`` or ``cancel``. If these rules
need to be modified for a model (e.g. change the states to archive), the
hook ``RecordLifespan._archive_domain`` can be extended.
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
<https://github.com/OCA/
server-tools/issues/new?body=module:%20
record_archiver%0Aversion:%20
9.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Credits
=======
Contributors
------------
* Yannick Vaucher <yannick.vaucher@camptocamp.com>
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
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 http://odoo-community.org.

88
record_archiver/__openerp__.py

@ -1,90 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
#
# Author: Yannick Vaucher
# Copyright 2015 Camptocamp SA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# © 2015 Yannick Vaucher (Camptocamp SA)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
{'name': 'Records Archiver', {'name': 'Records Archiver',
'version': '0.1',
'description': """
Records Archiver
================
Create a cron job that deactivates old records in order to optimize
performance.
Records are deactivated based on their last activity (write_date).
Configuration
=============
You can configure lifespan of each type of record in
`Settings -> Configuration -> Records Archiver`
A different lifespan can be configured for each model.
Usage
=====
Once the lifespans are configured, the cron will automatically
deactivate the old records.
Known issues / Roadmap
======================
The default behavior is to archive all records having a ``write_date`` <
lifespan and with a state being ``done`` or ``cancel``. If these rules
need to be modified for a model (e.g. change the states to archive), the
hook ``RecordLifespan._archive_domain`` can be extended.
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 `here
<https://github.com/OCA/server-tools/issues/new?body=module:%20record_archiver%0Aversion:%207.0%0A%0A**Steps%20to%20reproduce**%0A-%20...%0A%0A**Current%20behavior**%0A%0A**Expected%20behavior**>`_.
Credits
=======
Contributors
------------
* Yannick Vaucher <yannick.vaucher@camptocamp.com>
* Guewen Baconnier <guewen.baconnier@camptocamp.com>
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 http://odoo-community.org.
""",
'version': '9.0.1.0.0',
'author': 'Camptocamp,Odoo Community Association (OCA)', 'author': 'Camptocamp,Odoo Community Association (OCA)',
'license': 'AGPL-3', 'license': 'AGPL-3',
'category': 'misc', 'category': 'misc',

90
record_archiver/models/ir_model.py

@ -1,73 +1,39 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
#
#
# Authors: Guewen Baconnier
# Copyright 2015 Camptocamp SA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
#
# © 2015 Guewen Baconnier (Camptocamp SA)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from openerp import api, fields, models
from openerp.osv import orm, fields
class IrModel(orm.Model):
class IrModel(models.Model):
_inherit = 'ir.model' _inherit = 'ir.model'
def _compute_has_an_active_field(self, cr, uid, ids, name,
args, context=None):
res = {}
for model_id in ids:
active_field_ids = self.pool['ir.model.fields'].search(
cr, uid,
[('model_id', '=', model_id),
@api.multi
def _compute_has_an_active_field(self):
for model in self:
active_fields = self.env['ir.model.fields'].search(
[('model_id', '=', model.id),
('name', '=', 'active'), ('name', '=', 'active'),
], ],
limit=1,
context=context)
res[model_id] = bool(active_field_ids)
return res
limit=1)
model.has_an_active_field = bool(active_fields)
def _search_has_an_active_field(self, cr, uid, obj, name, args,
context=None):
if not len(args):
return []
fields_model = self.pool['ir.model.fields']
@api.model
def _search_has_an_active_field(self, operator, value):
if operator not in ['=', '!=']:
raise AssertionError('operator %s not allowed' % operator)
fields_model = self.env['ir.model.fields']
domain = [] domain = []
for field, operator, value in args:
assert field == name
active_field_ids = fields_model.search(
cr, uid, [('name', '=', 'active')], context=context)
active_fields = fields_model.read(cr, uid, active_field_ids,
fields=['model_id'],
load='_classic_write',
context=context)
model_ids = [field['model_id'] for field in active_fields]
if operator == '=' or not value:
domain.append(('id', 'in', model_ids))
elif operator == '!=' or value:
domain.append(('id', 'not in', model_ids))
else:
raise AssertionError('operator %s not allowed' % operator)
active_fields = fields_model.search(
[('name', '=', 'active')])
models = active_fields.mapped('model_id')
if operator == '=' and value or operator == '!=' and not value:
domain.append(('id', 'in', models.ids))
else:
domain.append(('id', 'not in', models.ids))
return domain return domain
_columns = {
'has_an_active_field': fields.function(
_compute_has_an_active_field,
fnct_search=_search_has_an_active_field,
string='Has an active field',
readonly=True,
type='boolean',
),
}
has_an_active_field = fields.Boolean(
compute=_compute_has_an_active_field,
search=_search_has_an_active_field,
string='Has an active field',
)

123
record_archiver/models/record_lifespan.py

@ -1,94 +1,74 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
#
# Author: Yannick Vaucher
# Copyright 2015 Camptocamp SA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# © 2015-2016 Yannick Vaucher (Camptocamp SA)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import logging import logging
from datetime import datetime from datetime import datetime
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from openerp.osv import orm, fields
from openerp.tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT
from openerp import api, exceptions, fields, models
from openerp.tools.translate import _ from openerp.tools.translate import _
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
class RecordLifespan(orm.Model):
class RecordLifespan(models.Model):
""" Configure records lifespans per model """ Configure records lifespans per model
After the lifespan is expired (compared to the `write_date` of the After the lifespan is expired (compared to the `write_date` of the
records), the records are deactivated. records), the records are deactivated.
""" """
_name = 'record.lifespan' _name = 'record.lifespan'
_order = 'model' _order = 'model'
_columns = {
'model_id': fields.many2one(
'ir.model',
string='Model',
required=True,
domain=[('has_an_active_field', '=', True)],
),
'model': fields.related(
'model_id', 'model',
string='Model Name',
type='char',
readonly=True,
store=True,
),
'months': fields.integer(
"Months",
required=True,
help="Number of month after which the records will be set to "
"inactive based on their write date"),
}
model_id = fields.Many2one(
'ir.model',
string='Model',
required=True,
domain=[('has_an_active_field', '=', True)],
)
model = fields.Char(
related='model_id.model',
string='Model Name',
store=True,
)
months = fields.Integer(
required=True,
help="Number of month after which the records will be set to inactive "
"based on their write date"
)
_sql_constraints = [ _sql_constraints = [
('months_gt_0', 'check (months > 0)', ('months_gt_0', 'check (months > 0)',
"Months must be a value greater than 0"), "Months must be a value greater than 0"),
] ]
def _scheduler_archive_records(self, cr, uid, context=None):
lifespan_ids = self.search(cr, uid, [], context=context)
@api.model
def _scheduler_archive_records(self):
lifespans = self.search([])
_logger.info('Records archiver starts archiving records') _logger.info('Records archiver starts archiving records')
for lifespan_id in lifespan_ids:
for lifespan in lifespans:
try: try:
self.archive_records(cr, uid, [lifespan_id], context=context)
except orm.except_orm as e:
lifespan.archive_records()
except exceptions.UserError as e:
_logger.error("Archiver error:\n%s", e[1]) _logger.error("Archiver error:\n%s", e[1])
_logger.info('Rusty Records now rest in peace') _logger.info('Rusty Records now rest in peace')
return True return True
def _archive_domain(self, cr, uid, lifespan, expiration_date,
context=None):
@api.multi
def _archive_domain(self, expiration_date):
""" Returns the domain used to find the records to archive. """ Returns the domain used to find the records to archive.
Can be inherited to change the archived records for a model. Can be inherited to change the archived records for a model.
""" """
model = self.pool[lifespan.model]
domain = [('write_date', '<', expiration_date),
]
model = self.env[self.model_id.model]
domain = [('write_date', '<', expiration_date)]
if 'state' in model._columns: if 'state' in model._columns:
domain += [('state', 'in', ('done', 'cancel'))] domain += [('state', 'in', ('done', 'cancel'))]
return domain return domain
def _archive_lifespan_records(self, cr, uid, lifespan, context=None):
@api.multi
def _archive_lifespan_records(self):
""" Archive the records for a lifespan, so for a model. """ Archive the records for a lifespan, so for a model.
Can be inherited to customize the archive strategy. Can be inherited to customize the archive strategy.
@ -97,37 +77,38 @@ class RecordLifespan(orm.Model):
Only done and canceled records will be deactivated. Only done and canceled records will be deactivated.
""" """
self.ensure_one()
today = datetime.today() today = datetime.today()
model = self.pool.get(lifespan.model)
if not model:
raise orm.except_orm(
_('Error'),
_('Model %s not found') % lifespan.model)
model_name = self.model_id.model
model = self.env[model_name]
if not isinstance(model, models.Model):
raise exceptions.UserError(
_('Model %s not found') % model_name)
if 'active' not in model._columns: if 'active' not in model._columns:
raise orm.except_orm(
_('Error'),
_('Model %s has no active field') % lifespan.model)
raise exceptions.UserError(
_('Model %s has no active field') % model_name)
delta = relativedelta(months=lifespan.months)
expiration_date = (today - delta).strftime(DATE_FORMAT)
delta = relativedelta(months=self.months)
expiration_date = fields.Datetime.to_string(today - delta)
domain = self._archive_domain(cr, uid, lifespan, expiration_date,
context=context)
rec_ids = model.search(cr, uid, domain, context=context)
if not rec_ids:
domain = self._archive_domain(expiration_date)
recs = model.search(domain)
if not recs:
return return
# use a SQL query to bypass tracking always messages on write for # use a SQL query to bypass tracking always messages on write for
# object inheriting mail.thread # object inheriting mail.thread
query = ("UPDATE %s SET active = FALSE WHERE id in %%s" query = ("UPDATE %s SET active = FALSE WHERE id in %%s"
) % model._table ) % model._table
cr.execute(query, (tuple(rec_ids),))
self.env.cr.execute(query, (tuple(recs.ids),))
recs.invalidate_cache()
_logger.info( _logger.info(
'Archived %s %s older than %s', 'Archived %s %s older than %s',
len(rec_ids), lifespan.model, expiration_date)
len(recs.ids), model_name, expiration_date)
def archive_records(self, cr, uid, ids, context=None):
@api.multi
def archive_records(self):
""" Call the archiver for several record lifespans """ """ Call the archiver for several record lifespans """
for lifespan in self.browse(cr, uid, ids, context=context):
self._archive_lifespan_records(cr, uid, lifespan, context=context)
for lifespan in self:
lifespan._archive_lifespan_records()
return True return True

6
record_archiver/tests/__init__.py

@ -2,9 +2,3 @@
from . import test_active_search from . import test_active_search
from . import test_archive from . import test_archive
checks = [
test_active_search,
test_archive,
]

70
record_archiver/tests/test_active_search.py

@ -1,56 +1,36 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
#
#
# Authors: Guewen Baconnier
# Copyright 2015 Camptocamp SA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
#
# © 2015 Guewen Baconnier (Camptocamp SA)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
import openerp.tests.common as common import openerp.tests.common as common
class TestActiveSearch(common.TransactionCase): class TestActiveSearch(common.TransactionCase):
def test_model_with_active_field(self): def test_model_with_active_field(self):
cr, uid = self.cr, self.uid
IrModel = self.registry('ir.model')
partner_model_id = IrModel.search(cr, uid,
[('model', '=', 'res.partner')],
limit=1)[0]
partner_model = IrModel.browse(cr, uid, partner_model_id)
IrModel = self.env['ir.model']
partner_model = IrModel.search([('model', '=', 'res.partner')],
limit=1)
self.assertTrue(partner_model.has_an_active_field) self.assertTrue(partner_model.has_an_active_field)
self.assertIn(partner_model_id,
IrModel.search(cr, uid,
[('has_an_active_field', '=', True)]))
self.assertIn(partner_model_id,
IrModel.search(cr, uid,
[('has_an_active_field', '!=', False)]))
self.assertIn(partner_model,
IrModel.search([('has_an_active_field', '=', True)]))
self.assertIn(partner_model,
IrModel.search([('has_an_active_field', '!=', False)]))
self.assertNotIn(partner_model,
IrModel.search([('has_an_active_field', '!=', True)]))
self.assertNotIn(partner_model,
IrModel.search([('has_an_active_field', '=', False)]))
def test_model_without_active_field(self): def test_model_without_active_field(self):
cr, uid = self.cr, self.uid
IrModel = self.registry('ir.model')
country_model_id = IrModel.search(cr, uid,
[('model', '=', 'res.country')],
limit=1)
country_model = IrModel.browse(cr, uid, country_model_id[0])
IrModel = self.env['ir.model']
country_model = IrModel.search([('model', '=', 'res.country')],
limit=1)
self.assertFalse(country_model.has_an_active_field) self.assertFalse(country_model.has_an_active_field)
self.assertNotIn(country_model_id,
IrModel.search(cr, uid,
[('has_an_active_field', '=', False)]))
self.assertNotIn(country_model_id,
IrModel.search(cr, uid,
[('has_an_active_field', '!=', True)]))
self.assertIn(country_model,
IrModel.search([('has_an_active_field', '!=', True)]))
self.assertIn(country_model,
IrModel.search([('has_an_active_field', '=', False)]))
self.assertNotIn(country_model,
IrModel.search([('has_an_active_field', '=', True)]))
self.assertNotIn(country_model,
IrModel.search([('has_an_active_field', '!=', False)])
)

74
record_archiver/tests/test_archive.py

@ -1,24 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
#
#
# Authors: Guewen Baconnier
# Copyright 2015 Camptocamp SA
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
#
# © 2015 Guewen Baconnier (Camptocamp SA)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
from datetime import datetime, timedelta from datetime import datetime, timedelta
import openerp.tests.common as common import openerp.tests.common as common
@ -27,42 +9,42 @@ class TestArchive(common.TransactionCase):
def setUp(self): def setUp(self):
super(TestArchive, self).setUp() super(TestArchive, self).setUp()
self.Partner = self.registry('res.partner')
cr, uid = self.cr, self.uid
self.partner1_id = self.Partner.create(cr, uid,
{'name': 'test user 1'})
self.partner2_id = self.Partner.create(cr, uid,
{'name': 'test user 2'})
self.partner3_id = self.Partner.create(cr, uid,
{'name': 'test user 3'})
Partner = self.env['res.partner']
self.partner1 = Partner.create(
{'name': 'test user 1'})
self.partner2 = Partner.create(
{'name': 'test user 2'})
self.partner3 = Partner.create(
{'name': 'test user 3'})
old_date = datetime.now() - timedelta(days=365) old_date = datetime.now() - timedelta(days=365)
self.cr.execute('UPDATE res_partner SET write_date = %s '
'WHERE id IN %s', (old_date, tuple([self.partner2_id,
self.partner3_id]))
)
self.Lifespan = self.registry('record.lifespan')
self.env.cr.execute(
'UPDATE res_partner SET write_date = %s '
'WHERE id IN %s', (old_date, tuple([self.partner2.id,
self.partner3.id]))
)
self.Lifespan = self.env['record.lifespan']
self.model_id = self.ref('base.model_res_partner') self.model_id = self.ref('base.model_res_partner')
@common.at_install(False)
@common.post_install(True)
def test_lifespan(self): def test_lifespan(self):
cr, uid = self.cr, self.uid
lifespan_id = self.Lifespan.create(
cr, uid,
lifespan = self.Lifespan.create(
{'model_id': self.model_id, {'model_id': self.model_id,
'months': 3, 'months': 3,
}) })
self.Lifespan.archive_records(cr, uid, [lifespan_id])
self.assertTrue(self.Partner.browse(cr, uid, self.partner1_id).active)
self.assertFalse(self.Partner.browse(cr, uid, self.partner2_id).active)
self.assertFalse(self.Partner.browse(cr, uid, self.partner3_id).active)
lifespan.archive_records()
self.assertTrue(self.partner1.active)
self.assertFalse(self.partner2.active)
self.assertFalse(self.partner3.active)
@common.at_install(False)
@common.post_install(True)
def test_scheduler(self): def test_scheduler(self):
cr, uid = self.cr, self.uid
self.Lifespan.create( self.Lifespan.create(
cr, uid,
{'model_id': self.model_id, {'model_id': self.model_id,
'months': 3, 'months': 3,
}) })
self.Lifespan._scheduler_archive_records(cr, uid)
self.assertTrue(self.Partner.browse(cr, uid, self.partner1_id).active)
self.assertFalse(self.Partner.browse(cr, uid, self.partner2_id).active)
self.assertFalse(self.Partner.browse(cr, uid, self.partner3_id).active)
self.Lifespan._scheduler_archive_records()
self.assertTrue(self.partner1.active)
self.assertFalse(self.partner2.active)
self.assertFalse(self.partner3.active)

8
record_archiver/views/record_lifespan_view.xml

@ -41,8 +41,14 @@
</field> </field>
</record> </record>
<menuitem id="menu_record_archiver"
name="Record Archiver"
parent="base.menu_administration"
sequence="10"/>
<menuitem id="menu_record_lifespan_config" <menuitem id="menu_record_lifespan_config"
parent="base.menu_config"
name="Lifespans"
parent="menu_record_archiver"
sequence="20" sequence="20"
action="action_record_lifespan_view"/> action="action_record_lifespan_view"/>

Loading…
Cancel
Save