From 5bb549d9b46190be0e6500f9a7877756d586276b Mon Sep 17 00:00:00 2001 From: Yannick Vaucher Date: Wed, 6 Apr 2016 19:46:17 +0200 Subject: [PATCH] [PORT] [9.0] record_archiver --- record_archiver/README.rst | 71 ++++++++++ record_archiver/__openerp__.py | 88 +------------ record_archiver/models/ir_model.py | 90 ++++--------- record_archiver/models/record_lifespan.py | 123 ++++++++---------- record_archiver/tests/__init__.py | 6 - record_archiver/tests/test_active_search.py | 70 ++++------ record_archiver/tests/test_archive.py | 74 ++++------- .../views/record_lifespan_view.xml | 8 +- 8 files changed, 214 insertions(+), 316 deletions(-) create mode 100644 record_archiver/README.rst diff --git a/record_archiver/README.rst b/record_archiver/README.rst new file mode 100644 index 000000000..82729d096 --- /dev/null +++ b/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 +`_. 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 +======= + +Contributors +------------ + +* Yannick Vaucher +* Guewen Baconnier + +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. diff --git a/record_archiver/__openerp__.py b/record_archiver/__openerp__.py index f76b83f4c..d9ec2045b 100644 --- a/record_archiver/__openerp__.py +++ b/record_archiver/__openerp__.py @@ -1,90 +1,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 . -# - +# © 2015 Yannick Vaucher (Camptocamp SA) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). {'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 -`_. 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 -`_. - - -Credits -======= - -Contributors ------------- - -* Yannick Vaucher -* Guewen Baconnier - -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)', 'license': 'AGPL-3', 'category': 'misc', diff --git a/record_archiver/models/ir_model.py b/record_archiver/models/ir_model.py index 8122c43a1..e3865ab9f 100644 --- a/record_archiver/models/ir_model.py +++ b/record_archiver/models/ir_model.py @@ -1,73 +1,39 @@ # -*- 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 . -# -# +# © 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' - 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'), ], - 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 = [] - 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 - _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', + ) diff --git a/record_archiver/models/record_lifespan.py b/record_archiver/models/record_lifespan.py index 510742cd4..158ecf529 100644 --- a/record_archiver/models/record_lifespan.py +++ b/record_archiver/models/record_lifespan.py @@ -1,94 +1,74 @@ # -*- 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 . -# +# © 2015-2016 Yannick Vaucher (Camptocamp SA) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import logging from datetime import datetime 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 _ _logger = logging.getLogger(__name__) -class RecordLifespan(orm.Model): +class RecordLifespan(models.Model): """ Configure records lifespans per model After the lifespan is expired (compared to the `write_date` of the records), the records are deactivated. """ _name = 'record.lifespan' - _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 = [ ('months_gt_0', 'check (months > 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') - for lifespan_id in lifespan_ids: + for lifespan in lifespans: 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.info('Rusty Records now rest in peace') 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. 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: domain += [('state', 'in', ('done', 'cancel'))] 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. Can be inherited to customize the archive strategy. @@ -97,37 +77,38 @@ class RecordLifespan(orm.Model): Only done and canceled records will be deactivated. """ + self.ensure_one() 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: - 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 # use a SQL query to bypass tracking always messages on write for # object inheriting mail.thread query = ("UPDATE %s SET active = FALSE WHERE id in %%s" ) % model._table - cr.execute(query, (tuple(rec_ids),)) + self.env.cr.execute(query, (tuple(recs.ids),)) + recs.invalidate_cache() _logger.info( '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 """ - 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 diff --git a/record_archiver/tests/__init__.py b/record_archiver/tests/__init__.py index 9ea217c5d..9c2e45c5e 100644 --- a/record_archiver/tests/__init__.py +++ b/record_archiver/tests/__init__.py @@ -2,9 +2,3 @@ from . import test_active_search from . import test_archive - - -checks = [ - test_active_search, - test_archive, -] diff --git a/record_archiver/tests/test_active_search.py b/record_archiver/tests/test_active_search.py index f96bb7365..ef5076aff 100644 --- a/record_archiver/tests/test_active_search.py +++ b/record_archiver/tests/test_active_search.py @@ -1,56 +1,36 @@ # -*- 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 . -# -# - - +# © 2015 Guewen Baconnier (Camptocamp SA) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). import openerp.tests.common as common class TestActiveSearch(common.TransactionCase): 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.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): - 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.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)]) + ) diff --git a/record_archiver/tests/test_archive.py b/record_archiver/tests/test_archive.py index 93ba06772..8e16e81d4 100644 --- a/record_archiver/tests/test_archive.py +++ b/record_archiver/tests/test_archive.py @@ -1,24 +1,6 @@ # -*- 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 . -# -# - +# © 2015 Guewen Baconnier (Camptocamp SA) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from datetime import datetime, timedelta import openerp.tests.common as common @@ -27,42 +9,42 @@ class TestArchive(common.TransactionCase): def setUp(self): 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) - 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') + @common.at_install(False) + @common.post_install(True) 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, '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): - cr, uid = self.cr, self.uid self.Lifespan.create( - cr, uid, {'model_id': self.model_id, '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) diff --git a/record_archiver/views/record_lifespan_view.xml b/record_archiver/views/record_lifespan_view.xml index 45d69a31c..80712977f 100644 --- a/record_archiver/views/record_lifespan_view.xml +++ b/record_archiver/views/record_lifespan_view.xml @@ -41,8 +41,14 @@ + +