diff --git a/record_archiver/README.rst b/record_archiver/README.rst new file mode 100644 index 000000000..774f631a1 --- /dev/null +++ b/record_archiver/README.rst @@ -0,0 +1,103 @@ +================ +Records Archiver +================ + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/11.0/record_archiver + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-11-0/server-tools-11-0-record_archiver + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/149/11.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Create a cron job that deactivates old records in order to optimize +performance. + +Records are deactivated based on their last activity (write_date). + +**Table of contents** + +.. contents:: + :local: + +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. + +Alternatively, you can provide a comma-separated list of states to +``record.lifespan`` records to redefine a set of record states that +should be archived. + +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 `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Yannick Vaucher +* Guewen Baconnier + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. 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/__manifest__.py b/record_archiver/__manifest__.py new file mode 100644 index 000000000..b71e3494a --- /dev/null +++ b/record_archiver/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2015 Yannick Vaucher (Camptocamp SA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + 'name': 'Records Archiver', + 'version': '11.0.1.0.0', + 'author': 'Camptocamp, Odoo Community Association (OCA)', + 'license': 'AGPL-3', + 'category': 'misc', + 'depends': ['base'], + 'website': 'https://github.com/OCA/server-tools', + 'data': [ + 'security/ir.model.access.csv', + 'views/record_lifespan_view.xml', + 'data/cron.xml', + ], +} diff --git a/record_archiver/data/cron.xml b/record_archiver/data/cron.xml new file mode 100644 index 000000000..6708cd5e8 --- /dev/null +++ b/record_archiver/data/cron.xml @@ -0,0 +1,17 @@ + + + + + Records Archiver + + + 1 + months + -1 + + + code + model._scheduler_archive_records() + + + 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..be87319bb --- /dev/null +++ b/record_archiver/models/ir_model.py @@ -0,0 +1,38 @@ +# Copyright 2015 Guewen Baconnier (Camptocamp SA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo import api, fields, models + + +class IrModel(models.Model): + _inherit = 'ir.model' + + @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) + model.has_an_active_field = bool(active_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 = [] + 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 + + 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 new file mode 100644 index 000000000..79693c559 --- /dev/null +++ b/record_archiver/models/record_lifespan.py @@ -0,0 +1,144 @@ +# Copyright 2015-2016 Yannick Vaucher (Camptocamp SA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +import logging + +from datetime import datetime +from dateutil.relativedelta import relativedelta + +from odoo import _, api, exceptions, fields, models + +_logger = logging.getLogger(__name__) + + +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_name' + + model_id = fields.Many2one( + 'ir.model', + string='Model', + required=True, + domain=[('has_an_active_field', '=', True)], + ) + model_name = fields.Char( + related='model_id.model', + readonly=True, + string='Model Name', + ) + months = fields.Integer( + required=True, + help="Number of month after which the records will be set to inactive" + " based on their write date", + ) + archive_states = fields.Char( + help="Comma-separated list of states in which records should be" + " archived. Implicit value is `'done, cancel')`.", + ) + + _sql_constraints = [ + ('months_gt_0', 'check (months > 0)', + "Months must be a value greater than 0"), + ] + + @api.constrains('archive_states') + def _check_archive_states(self): + for lifespan in self: + if not lifespan.archive_states: + continue + model = self.env[lifespan.model_id.model] + state_field = model.fields_get().get('state', {}) + if not state_field or state_field['type'] != 'selection': + continue + allowed_states = [ + state[0] + for state in state_field.get('selection', [('')]) + ] + if not all(archive_state in allowed_states + for archive_state in lifespan._get_archive_states()): + raise exceptions.ValidationError(_( + 'Invalid set of states for "%s" model:\n' + '%s\n' + 'Valid states:\n%s' + ) % ( + lifespan.model_id.name, + lifespan.archive_states, + '\n'.join('- {}'.format(s) for s in allowed_states), + )) + + @api.model + def _scheduler_archive_records(self): + lifespans = self.search([]) + _logger.info('Records archiver starts archiving records') + for lifespan in lifespans: + try: + 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 + + @api.multi + def _get_archive_states(self): + self.ensure_one() + if not self.archive_states: + return ['done', 'cancel'] + return [s.strip() for s in self.archive_states.split(',') if s.strip()] + + @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. + """ + self.ensure_one() + model = self.env[self.model_id.model] + domain = [('write_date', '<', expiration_date)] + if 'state' in model.fields_get_keys(): + domain += [('state', 'in', self._get_archive_states())] + return domain + + @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. + 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. + + """ + self.ensure_one() + today = datetime.today() + 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.fields_get_keys(): + raise exceptions.UserError( + _('Model %s has no active field') % model_name) + + delta = relativedelta(months=self.months) + expiration_date = fields.Datetime.to_string(today - delta) + + domain = self._archive_domain(expiration_date) + recs = model.search(domain) + if not recs: + return + + recs.with_context(tracking_disable=True).toggle_active() + _logger.info( + 'Archived %s %s older than %s', + len(recs.ids), model_name, expiration_date) + + @api.multi + def archive_records(self): + """Call the archiver for several record lifespans.""" + for lifespan in self: + lifespan._archive_lifespan_records() + return True diff --git a/record_archiver/readme/CONFIGURE.rst b/record_archiver/readme/CONFIGURE.rst new file mode 100644 index 000000000..9373c6ee9 --- /dev/null +++ b/record_archiver/readme/CONFIGURE.rst @@ -0,0 +1,4 @@ +You can configure lifespan of each type of record in +`Settings -> Configuration -> Records Archiver` + +A different lifespan can be configured for each model. diff --git a/record_archiver/readme/CONTRIBUTORS.rst b/record_archiver/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..50e89004a --- /dev/null +++ b/record_archiver/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* Yannick Vaucher +* Guewen Baconnier +* Artem Kostyuk diff --git a/record_archiver/readme/DESCRIPTION.rst b/record_archiver/readme/DESCRIPTION.rst new file mode 100644 index 000000000..c1c15cbe9 --- /dev/null +++ b/record_archiver/readme/DESCRIPTION.rst @@ -0,0 +1,4 @@ +Create a cron job that deactivates old records in order to optimize +performance. + +Records are deactivated based on their last activity (write_date). diff --git a/record_archiver/readme/ROADMAP.rst b/record_archiver/readme/ROADMAP.rst new file mode 100644 index 000000000..148f07391 --- /dev/null +++ b/record_archiver/readme/ROADMAP.rst @@ -0,0 +1,4 @@ +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. diff --git a/record_archiver/readme/USAGE.rst b/record_archiver/readme/USAGE.rst new file mode 100644 index 000000000..8a41aaf0c --- /dev/null +++ b/record_archiver/readme/USAGE.rst @@ -0,0 +1,2 @@ +Once the lifespans are configured, the cron will automatically +deactivate the old records. 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/description/icon.png b/record_archiver/static/description/icon.png new file mode 100644 index 000000000..0959854c1 Binary files /dev/null and b/record_archiver/static/description/icon.png differ diff --git a/record_archiver/static/description/index.html b/record_archiver/static/description/index.html new file mode 100644 index 000000000..8acdc68fa --- /dev/null +++ b/record_archiver/static/description/index.html @@ -0,0 +1,443 @@ + + + + + + +Records Archiver + + + +
+

Records Archiver

+ + +

Beta License: AGPL-3 OCA/server-tools Translate me on Weblate Try me on Runbot

+

Create a cron job that deactivates old records in order to optimize +performance.

+

Records are deactivated based on their last activity (write_date).

+

Table of contents

+ +
+

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.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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.

+

This module is part of the OCA/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/record_archiver/tests/__init__.py b/record_archiver/tests/__init__.py new file mode 100644 index 000000000..4c88d8bc2 --- /dev/null +++ b/record_archiver/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_active_search +from . import 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..214522816 --- /dev/null +++ b/record_archiver/tests/test_active_search.py @@ -0,0 +1,35 @@ +# Copyright 2015 Guewen Baconnier (Camptocamp SA) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import odoo.tests.common as common + + +class TestActiveSearch(common.TransactionCase): + + def test_model_with_active_field(self): + 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, + 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): + IrModel = self.env['ir.model'] + country_model = IrModel.search([('model', '=', 'res.country')], + limit=1) + self.assertFalse(country_model.has_an_active_field) + 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 new file mode 100644 index 000000000..441bee8f7 --- /dev/null +++ b/record_archiver/tests/test_archive.py @@ -0,0 +1,123 @@ +# Copyright 2015 Guewen Baconnier (Camptocamp SA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from datetime import datetime, timedelta +import odoo.tests.common as common +from odoo.exceptions import ValidationError + + +class TestArchive(common.SavepointCase): + + at_install = False + post_install = True + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict( + cls.env.context, + tracking_disable=True, + )) + Partner = cls.env['res.partner'] + Cron = cls.env['ir.cron'] # has both `active` and `state` fields + + cls.Lifespan = cls.env['record.lifespan'] + cls.partner_model = cls.env.ref('base.model_res_partner') + cls.cron_model = cls.env.ref('base.model_ir_cron') + + cls.partner1 = Partner.create({ + 'name': 'test user 1', + }) + cls.partner2 = Partner.create({ + 'name': 'test user 2', + }) + cls.partner3 = Partner.create({ + 'name': 'test user 3', + }) + cls.cron1 = Cron.create({ + 'active': True, + 'model_id': cls.partner_model.id, + 'name': 'Dummy cron 1', + 'state': 'code', + 'code': 'model.browse()', + }) + cls.cron2 = cls.cron1.copy({ + 'name': 'Dummy cron 2', + 'state': 'multi', + }) + cls.cron3 = cls.cron1.copy({ + 'name': 'Dummy cron 3', + 'state': 'object_create', + }) + old_date = datetime.now() - timedelta(days=365) + cls.env.cr.execute( + 'UPDATE res_partner SET write_date = %s ' + 'WHERE id IN %s', (old_date, (cls.partner2.id, cls.partner3.id)) + ) + cls.env.cr.execute( + 'UPDATE ir_cron SET write_date = %s ' + 'WHERE id IN %s', (old_date, (cls.cron2.id, cls.cron3.id))) + + def test_get_archive_states(self): + # Valid ir.cron states: code, object_create, object_write, multi + archive_states_valid_variants = [ + 'code, multi, object_create', + 'code,multi,object_create', + 'code,multi,object_create,', + ' code , multi, object_create', + ] + xpected = ['code', 'multi', 'object_create'] + guineapig = self.Lifespan.create({ + 'model_id': self.cron_model.id, + 'months': 12, + }) + for variant in archive_states_valid_variants: + guineapig.archive_states = variant + self.assertEqual(guineapig._get_archive_states(), xpected) + + def test_states_constraint_valid(self): + # Valid ir.cron states: code, object_create, object_write, multi + self.Lifespan.create({ + 'model_id': self.cron_model.id, + 'months': 12, + 'archive_states': 'code', + }) + + def test_states_constraint_invalid(self): + with self.assertRaises(ValidationError): + # Valid ir.cron states: code, object_create, object_write, multi + self.Lifespan.create({ + 'model_id': self.cron_model.id, + 'months': 12, + 'archive_states': 'none, of, these, are, valid, states', + }) + + def test_lifespan(self): + lifespan = self.Lifespan.create({ + 'model_id': self.partner_model.id, + 'months': 3, + }) + lifespan.archive_records() + self.assertTrue(self.partner1.active) + self.assertFalse(self.partner2.active) + self.assertFalse(self.partner3.active) + + def test_lifespan_states(self): + lifespan = self.Lifespan.create({ + 'model_id': self.cron_model.id, + 'months': 3, + 'archive_states': 'code, multi', + }) + lifespan.archive_records() + self.assertTrue(self.cron1.active) # state: code, fresh + self.assertFalse(self.cron2.active) # state: multi, fresh + self.assertTrue(self.cron3.active) # state: object_create, outdated + + def test_scheduler(self): + self.Lifespan.create({ + 'model_id': self.partner_model.id, + 'months': 3, + }) + 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 new file mode 100644 index 000000000..3fcdf3eb5 --- /dev/null +++ b/record_archiver/views/record_lifespan_view.xml @@ -0,0 +1,55 @@ + + + + + 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. +

+
+
+ + + + + +