diff --git a/record_archiver/__init__.py b/record_archiver/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/record_archiver/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/record_archiver/__openerp__.py b/record_archiver/__openerp__.py new file mode 100644 index 000000000..f76b83f4c --- /dev/null +++ b/record_archiver/__openerp__.py @@ -0,0 +1,99 @@ +# -*- 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 . +# + +{'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. + """, + 'author': 'Camptocamp,Odoo Community Association (OCA)', + 'license': 'AGPL-3', + 'category': 'misc', + 'depends': ['base'], + 'website': 'www.camptocamp.com', + 'data': ['views/record_lifespan_view.xml', + 'data/cron.xml', + 'security/ir.model.access.csv', + ], + 'installable': True, + 'auto_install': False, + } diff --git a/record_archiver/data/cron.xml b/record_archiver/data/cron.xml new file mode 100644 index 000000000..5780f097a --- /dev/null +++ b/record_archiver/data/cron.xml @@ -0,0 +1,19 @@ + + + + + + Records Archiver + + + 1 + months + -1 + + + + + + + + diff --git a/record_archiver/i18n/record_archiver.pot b/record_archiver/i18n/record_archiver.pot new file mode 100644 index 000000000..68ed9a609 --- /dev/null +++ b/record_archiver/i18n/record_archiver.pot @@ -0,0 +1,99 @@ +# Translation of OpenERP Server. +# This file contains the translation of the following modules: +# * record_archiver +# +msgid "" +msgstr "" +"Project-Id-Version: OpenERP Server 7.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2015-07-01 12:35+0000\n" +"PO-Revision-Date: 2015-07-01 12:35+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: record_archiver +#: model:ir.actions.act_window,help:record_archiver.action_record_lifespan_view +msgid "

\n" +" Click to define a new lifespan for a type of records.\n" +"

\n" +" Every record of model with a lifespan will be set to inactive\n" +" after the the defined months are elapsed. The lifespan is\n" +" based on the last write on a record.\n" +"

\n" +" " +msgstr "" + +#. module: record_archiver +#: code:addons/record_archiver/models/record_lifespan.py:104 +#: code:addons/record_archiver/models/record_lifespan.py:108 +#, python-format +msgid "Error" +msgstr "" + +#. module: record_archiver +#: field:ir.model,has_an_active_field:0 +msgid "Has an active field" +msgstr "" + +#. module: record_archiver +#: field:record.lifespan,model_id:0 +msgid "Model" +msgstr "" + +#. module: record_archiver +#: code:addons/record_archiver/models/record_lifespan.py:109 +#, python-format +msgid "Model %s has no active field" +msgstr "" + +#. module: record_archiver +#: code:addons/record_archiver/models/record_lifespan.py:105 +#, python-format +msgid "Model %s not found" +msgstr "" + +#. module: record_archiver +#: field:record.lifespan,model:0 +msgid "Model Name" +msgstr "" + +#. module: record_archiver +#: code:_description:0 +#: model:ir.model,name:record_archiver.model_ir_model +#, python-format +msgid "Models" +msgstr "" + +#. module: record_archiver +#: field:record.lifespan,months:0 +msgid "Months" +msgstr "" + +#. module: record_archiver +#: sql_constraint:record.lifespan:0 +msgid "Months must be a value greater than 0" +msgstr "" + +#. module: record_archiver +#: help:record.lifespan,months:0 +msgid "Number of month after which the records will be set to inactive based on their write date" +msgstr "" + +#. module: record_archiver +#: model:ir.actions.act_window,name:record_archiver.action_record_lifespan_view +#: model:ir.ui.menu,name:record_archiver.menu_record_lifespan_config +#: view:record.lifespan:0 +msgid "Records Archiver Lifespans" +msgstr "" + +#. module: record_archiver +#: code:_description:0 +#: model:ir.model,name:record_archiver.model_record_lifespan +#, python-format +msgid "record.lifespan" +msgstr "" + diff --git a/record_archiver/models/__init__.py b/record_archiver/models/__init__.py new file mode 100644 index 000000000..a48e87b41 --- /dev/null +++ b/record_archiver/models/__init__.py @@ -0,0 +1,2 @@ +from . import ir_model +from . import record_lifespan diff --git a/record_archiver/models/ir_model.py b/record_archiver/models/ir_model.py new file mode 100644 index 000000000..8122c43a1 --- /dev/null +++ b/record_archiver/models/ir_model.py @@ -0,0 +1,73 @@ +# -*- 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 . +# +# + +from openerp.osv import orm, fields + + +class IrModel(orm.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), + ('name', '=', 'active'), + ], + limit=1, + context=context) + res[model_id] = bool(active_field_ids) + return res + + 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'] + 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) + 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', + ), + } diff --git a/record_archiver/models/record_lifespan.py b/record_archiver/models/record_lifespan.py new file mode 100644 index 000000000..510742cd4 --- /dev/null +++ b/record_archiver/models/record_lifespan.py @@ -0,0 +1,133 @@ +# -*- 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 . +# +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.tools.translate import _ + +_logger = logging.getLogger(__name__) + + +class RecordLifespan(orm.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"), + } + + _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) + _logger.info('Records archiver starts archiving records') + for lifespan_id in lifespan_ids: + try: + self.archive_records(cr, uid, [lifespan_id], context=context) + except orm.except_orm 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): + """ 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), + ] + if 'state' in model._columns: + domain += [('state', 'in', ('done', 'cancel'))] + return domain + + def _archive_lifespan_records(self, cr, uid, lifespan, context=None): + """ Archive the records for a lifespan, so for a model. + + Can be inherited to customize the archive strategy. + The default strategy is to change the field ``active`` to False + on the records having a ``write_date`` older than the lifespan. + Only done and canceled records will be deactivated. + + """ + today = datetime.today() + model = self.pool.get(lifespan.model) + if not model: + raise orm.except_orm( + _('Error'), + _('Model %s not found') % lifespan.model) + if 'active' not in model._columns: + raise orm.except_orm( + _('Error'), + _('Model %s has no active field') % lifespan.model) + + delta = relativedelta(months=lifespan.months) + expiration_date = (today - delta).strftime(DATE_FORMAT) + + domain = self._archive_domain(cr, uid, lifespan, expiration_date, + context=context) + rec_ids = model.search(cr, uid, domain, context=context) + if not rec_ids: + 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),)) + _logger.info( + 'Archived %s %s older than %s', + len(rec_ids), lifespan.model, expiration_date) + + def archive_records(self, cr, uid, ids, context=None): + """ 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) + return True diff --git a/record_archiver/security/ir.model.access.csv b/record_archiver/security/ir.model.access.csv new file mode 100644 index 000000000..73b9057af --- /dev/null +++ b/record_archiver/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_record_lifespan,Record Lifespan,record_archiver.model_record_lifespan,base.group_no_one,1,1,1,1 diff --git a/record_archiver/static/src/img/icon.png b/record_archiver/static/src/img/icon.png new file mode 100644 index 000000000..0959854c1 Binary files /dev/null and b/record_archiver/static/src/img/icon.png differ diff --git a/record_archiver/tests/__init__.py b/record_archiver/tests/__init__.py new file mode 100644 index 000000000..9ea217c5d --- /dev/null +++ b/record_archiver/tests/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- + +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 new file mode 100644 index 000000000..f96bb7365 --- /dev/null +++ b/record_archiver/tests/test_active_search.py @@ -0,0 +1,56 @@ +# -*- 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 . +# +# + + +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) + 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)])) + + 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]) + 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)])) diff --git a/record_archiver/tests/test_archive.py b/record_archiver/tests/test_archive.py new file mode 100644 index 000000000..93ba06772 --- /dev/null +++ b/record_archiver/tests/test_archive.py @@ -0,0 +1,68 @@ +# -*- 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 . +# +# + +from datetime import datetime, timedelta +import openerp.tests.common as common + + +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'}) + 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.model_id = self.ref('base.model_res_partner') + + def test_lifespan(self): + cr, uid = self.cr, self.uid + lifespan_id = self.Lifespan.create( + cr, uid, + {'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) + + 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) diff --git a/record_archiver/views/record_lifespan_view.xml b/record_archiver/views/record_lifespan_view.xml new file mode 100644 index 000000000..45d69a31c --- /dev/null +++ b/record_archiver/views/record_lifespan_view.xml @@ -0,0 +1,50 @@ + + + + + + record.lifespan.tree + record.lifespan + + + + + + + + + + record.lifespan.search + record.lifespan + + + + + + + + + Records Archiver Lifespans + ir.actions.act_window + record.lifespan + form + tree + + +

+ Click to define a new lifespan for a type of records. +

+ Every record of model with a lifespan will be set to inactive + after the the defined months are elapsed. The lifespan is + based on the last write on a record. +

+
+
+ + + +
+