From 1f63b3e13539ed4e19b7f8cd016f063ec54ffe35 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 30 Jun 2015 10:33:31 +0200 Subject: [PATCH 01/14] Rename module From rusty_record_reaper_runner to record_archiver. Not that I dislike the old funny name, but I guess it will be easier to get what it does with the new (maybe too serious) name. --- record_archiver/__init__.py | 1 + record_archiver/__openerp__.py | 45 +++++++++ record_archiver/data/cron.xml | 19 ++++ record_archiver/models/__init__.py | 3 + record_archiver/models/company.py | 30 ++++++ record_archiver/models/record_lifespan.py | 110 ++++++++++++++++++++++ record_archiver/models/res_config.py | 70 ++++++++++++++ record_archiver/static/src/img/icon.png | Bin 0 -> 5881 bytes record_archiver/views/res_config.xml | 60 ++++++++++++ 9 files changed, 338 insertions(+) create mode 100644 record_archiver/__init__.py create mode 100644 record_archiver/__openerp__.py create mode 100644 record_archiver/data/cron.xml create mode 100644 record_archiver/models/__init__.py create mode 100644 record_archiver/models/company.py create mode 100644 record_archiver/models/record_lifespan.py create mode 100644 record_archiver/models/res_config.py create mode 100644 record_archiver/static/src/img/icon.png create mode 100644 record_archiver/views/res_config.xml 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..2ac5c486b --- /dev/null +++ b/record_archiver/__openerp__.py @@ -0,0 +1,45 @@ +# -*- 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': """ +Create a cron job that deactivates old records in order to optimize +performance. + +Records are deactivated based on their last activity (write_date). + +You can configure lifespan of each type of record in +`Settings -> Configuration -> Records Archiver` + +Lifespan is defined per record per company. + """, + 'author': 'Camptocamp', + 'maintainer': 'Camptocamp', + 'license': 'AGPL-3', + 'category': 'misc', + 'complexity': "easy", # easy, normal, expert + 'depends': ['base'], + 'website': 'www.camptocamp.com', + 'data': ['views/res_config.xml', + 'data/cron.xml'], + 'test': [], + '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..f98f39ed0 --- /dev/null +++ b/record_archiver/data/cron.xml @@ -0,0 +1,19 @@ + + + + + + Records Archiver + + + 1 + months + -1 + + + + + + + + diff --git a/record_archiver/models/__init__.py b/record_archiver/models/__init__.py new file mode 100644 index 000000000..17ec9c568 --- /dev/null +++ b/record_archiver/models/__init__.py @@ -0,0 +1,3 @@ +from . import company +from . import res_config +from . import record_lifespan diff --git a/record_archiver/models/company.py b/record_archiver/models/company.py new file mode 100644 index 000000000..2b3c410a3 --- /dev/null +++ b/record_archiver/models/company.py @@ -0,0 +1,30 @@ +# -*- 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 . +# +from openerp.osv import orm, fields + + +class Company(orm.Model): + _inherit = 'res.company' + + _columns = { + 'lifespan_ids': fields.one2many( + 'record.lifespan', + 'company_id', + string="Record Lifespans"), + } diff --git a/record_archiver/models/record_lifespan.py b/record_archiver/models/record_lifespan.py new file mode 100644 index 000000000..1a75ee229 --- /dev/null +++ b/record_archiver/models/record_lifespan.py @@ -0,0 +1,110 @@ +# -*- 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, osv +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': fields.char( + "Model", + required=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"), + 'company_id': fields.many2one( + 'res.company', + string="Company", + ondelete="cascade", + required=True), + } + + _sql_constraints = [ + ('model_uniq', 'unique(model, company_id)', + "A model can only have 1 lifespan per company"), + ('months_gt_0', 'check (months > 0)', + "Months must be a value greater than 0"), + ] + + def _scheduler_record_archiver(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 osv.except_osv as e: + _logger.error("Archiver error:\n%s", e[1]) + _logger.info('Rusty Records now rest in peace') + return True + + def archive_records(self, cr, uid, ids, context=None): + """ Search and deactivate old records for each configured lifespan + + Only done and cancelled records will be deactivated. + """ + lifespans = self.browse(cr, uid, ids, context=context) + today = datetime.today() + for lifespan in lifespans: + + model = self.pool[lifespan.model] + if not model: + raise osv.except_osv( + _('Error'), + _('Model %s not found') % lifespan.model) + if 'active' not in model._columns.keys(): + raise osv.except_osv( + _('Error'), + _('Model %s has no active field') % lifespan.model) + delta = relativedelta(months=lifespan.months) + expiration_date = (today - delta).strftime(DATE_FORMAT) + domain = [('write_date', '<', expiration_date), + ('company_id', '=', lifespan.company_id.id)] + if 'state' in model._columns.keys(): + domain += [('state', 'in', ('done', 'cancel'))] + rec_ids = model.search(cr, uid, domain, context=context) + + if not rec_ids: + continue + # 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) diff --git a/record_archiver/models/res_config.py b/record_archiver/models/res_config.py new file mode 100644 index 000000000..fb6c2bf61 --- /dev/null +++ b/record_archiver/models/res_config.py @@ -0,0 +1,70 @@ +# -*- 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 openerp.osv import orm, fields + +_logger = logging.getLogger(__name__) + + +class RecordArchiverConfigSettings(orm.TransientModel): + _name = 'record.archiver.config.settings' + _inherit = 'res.config.settings' + + _columns = { + 'company_id': fields.many2one('res.company', 'Company', required=True), + 'lifespan_ids': fields.related( + 'company_id', 'lifespan_ids', + string='Record Lifespans', + type='one2many', + relation='record.lifespan'), + } + + def _default_company(self, cr, uid, context=None): + user = self.pool.get('res.users').browse(cr, uid, uid, context=context) + return user.company_id.id + + _defaults = { + 'company_id': _default_company, + } + + def create(self, cr, uid, values, context=None): + _super = super(RecordArchiverConfigSettings, self) + rec_id = _super.create(cr, uid, values, context=context) + # Hack: to avoid some nasty bug, related fields are not written upon + # record creation. + # Hence we write on those fields here. + vals = {} + for fname, field in self._columns.iteritems(): + if isinstance(field, fields.related) and fname in values: + vals[fname] = values[fname] + self.write(cr, uid, [rec_id], vals, context=context) + return id + + def onchange_company_id(self, cr, uid, ids, company_id, context=None): + # update related fields + if not company_id: + return {'value': {}} + company = self.pool.get('res.company' + ).browse(cr, uid, company_id, context=context) + lifespan_ids = [l.id for l in company.lifespan_ids] + values = { + 'lifespan_ids': lifespan_ids, + } + return {'value': values} diff --git a/record_archiver/static/src/img/icon.png b/record_archiver/static/src/img/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0959854c1c44a9efcb8ca7c86173c5c6d7bc2ff8 GIT binary patch literal 5881 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 literal 0 HcmV?d00001 diff --git a/record_archiver/views/res_config.xml b/record_archiver/views/res_config.xml new file mode 100644 index 000000000..0642b4671 --- /dev/null +++ b/record_archiver/views/res_config.xml @@ -0,0 +1,60 @@ + + + + + + record archiver settings + record.archiver.config.settings + +
+
+
+ + +
+
+
+
+
+ + All following type of record will be harvested by the cron + based on write_date. If a record has made his time, it will be + deactivated. + +
+
+ + + + + + +
+
+
+ +
+
+ + + Configure Records Archiver + ir.actions.act_window + record.archiver.config.settings + form + inline + + + + +
+
From d6b61b7a4e56e2c9443553fa729089962f3ec570 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 30 Jun 2015 10:44:38 +0200 Subject: [PATCH 02/14] Make the archiver more extensible by extracting methods. Now we are able to extend the module to implement other archiving stategies. --- record_archiver/data/cron.xml | 2 +- record_archiver/models/record_lifespan.py | 92 ++++++++++++++--------- 2 files changed, 57 insertions(+), 37 deletions(-) diff --git a/record_archiver/data/cron.xml b/record_archiver/data/cron.xml index f98f39ed0..5780f097a 100644 --- a/record_archiver/data/cron.xml +++ b/record_archiver/data/cron.xml @@ -11,7 +11,7 @@ -1 - + diff --git a/record_archiver/models/record_lifespan.py b/record_archiver/models/record_lifespan.py index 1a75ee229..74d24d9ef 100644 --- a/record_archiver/models/record_lifespan.py +++ b/record_archiver/models/record_lifespan.py @@ -21,7 +21,7 @@ import logging from datetime import datetime from dateutil.relativedelta import relativedelta -from openerp.osv import orm, fields, osv +from openerp.osv import orm, fields from openerp.tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT from openerp.tools.translate import _ @@ -61,50 +61,70 @@ class RecordLifespan(orm.Model): "Months must be a value greater than 0"), ] - def _scheduler_record_archiver(self, cr, uid, context=None): + 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 osv.except_osv as e: + 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_records(self, cr, uid, ids, context=None): - """ Search and deactivate old records for each configured lifespan + 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), + ('company_id', '=', lifespan.company_id.id)] + if 'state' in model._columns.keys(): + 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. - Only done and cancelled records will be deactivated. """ - lifespans = self.browse(cr, uid, ids, context=context) today = datetime.today() - for lifespan in lifespans: - - model = self.pool[lifespan.model] - if not model: - raise osv.except_osv( - _('Error'), - _('Model %s not found') % lifespan.model) - if 'active' not in model._columns.keys(): - raise osv.except_osv( - _('Error'), - _('Model %s has no active field') % lifespan.model) - delta = relativedelta(months=lifespan.months) - expiration_date = (today - delta).strftime(DATE_FORMAT) - domain = [('write_date', '<', expiration_date), - ('company_id', '=', lifespan.company_id.id)] - if 'state' in model._columns.keys(): - domain += [('state', 'in', ('done', 'cancel'))] - rec_ids = model.search(cr, uid, domain, context=context) - - if not rec_ids: - continue - # 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) + model = self.pool[lifespan.model] + if not model: + raise orm.except_orm( + _('Error'), + _('Model %s not found') % lifespan.model) + if 'active' not in model._columns.keys(): + 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 From d4c053030e40e58534d50318c490b2663e4ae776 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 30 Jun 2015 11:18:51 +0200 Subject: [PATCH 03/14] Use a m2o for the models --- record_archiver/models/company.py | 2 +- record_archiver/models/record_lifespan.py | 23 ++++++++++++++++------- record_archiver/models/res_config.py | 8 ++++---- record_archiver/views/res_config.xml | 4 ++-- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/record_archiver/models/company.py b/record_archiver/models/company.py index 2b3c410a3..cf6a9c361 100644 --- a/record_archiver/models/company.py +++ b/record_archiver/models/company.py @@ -23,7 +23,7 @@ class Company(orm.Model): _inherit = 'res.company' _columns = { - 'lifespan_ids': fields.one2many( + 'record_lifespan_ids': fields.one2many( 'record.lifespan', 'company_id', string="Record Lifespans"), diff --git a/record_archiver/models/record_lifespan.py b/record_archiver/models/record_lifespan.py index 74d24d9ef..e6b1928dd 100644 --- a/record_archiver/models/record_lifespan.py +++ b/record_archiver/models/record_lifespan.py @@ -39,9 +39,18 @@ class RecordLifespan(orm.Model): _order = 'model' _columns = { - 'model': fields.char( - "Model", - required=True), + 'model_id': fields.many2one( + 'ir.model', + string='Model', + required=True, + ), + 'model': fields.related( + 'model_id', 'model', + string='Model Name', + type='char', + readonly=True, + store=True, + ), 'months': fields.integer( "Months", required=True, @@ -55,7 +64,7 @@ class RecordLifespan(orm.Model): } _sql_constraints = [ - ('model_uniq', 'unique(model, company_id)', + ('model_uniq', 'unique(model_id, company_id)', "A model can only have 1 lifespan per company"), ('months_gt_0', 'check (months > 0)', "Months must be a value greater than 0"), @@ -81,7 +90,7 @@ class RecordLifespan(orm.Model): model = self.pool[lifespan.model] domain = [('write_date', '<', expiration_date), ('company_id', '=', lifespan.company_id.id)] - if 'state' in model._columns.keys(): + if 'state' in model._columns: domain += [('state', 'in', ('done', 'cancel'))] return domain @@ -95,12 +104,12 @@ class RecordLifespan(orm.Model): """ today = datetime.today() - model = self.pool[lifespan.model] + 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.keys(): + if 'active' not in model._columns: raise orm.except_orm( _('Error'), _('Model %s has no active field') % lifespan.model) diff --git a/record_archiver/models/res_config.py b/record_archiver/models/res_config.py index fb6c2bf61..3ca7f4b9c 100644 --- a/record_archiver/models/res_config.py +++ b/record_archiver/models/res_config.py @@ -29,8 +29,8 @@ class RecordArchiverConfigSettings(orm.TransientModel): _columns = { 'company_id': fields.many2one('res.company', 'Company', required=True), - 'lifespan_ids': fields.related( - 'company_id', 'lifespan_ids', + 'record_lifespan_ids': fields.related( + 'company_id', 'record_lifespan_ids', string='Record Lifespans', type='one2many', relation='record.lifespan'), @@ -63,8 +63,8 @@ class RecordArchiverConfigSettings(orm.TransientModel): return {'value': {}} company = self.pool.get('res.company' ).browse(cr, uid, company_id, context=context) - lifespan_ids = [l.id for l in company.lifespan_ids] + lifespan_ids = [l.id for l in company.record_lifespan_ids] values = { - 'lifespan_ids': lifespan_ids, + 'record_lifespan_ids': lifespan_ids, } return {'value': values} diff --git a/record_archiver/views/res_config.xml b/record_archiver/views/res_config.xml index 0642b4671..83d85306d 100644 --- a/record_archiver/views/res_config.xml +++ b/record_archiver/views/res_config.xml @@ -31,9 +31,9 @@
- + - + From db0f88479ecdce5ae998dbdc01b81bd3240ea712 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Tue, 30 Jun 2015 11:28:43 +0200 Subject: [PATCH 04/14] Hide models without an active field --- record_archiver/models/__init__.py | 1 + record_archiver/models/ir_model.py | 73 +++++++++++++++++++++++ record_archiver/models/record_lifespan.py | 1 + 3 files changed, 75 insertions(+) create mode 100644 record_archiver/models/ir_model.py diff --git a/record_archiver/models/__init__.py b/record_archiver/models/__init__.py index 17ec9c568..8637ee398 100644 --- a/record_archiver/models/__init__.py +++ b/record_archiver/models/__init__.py @@ -1,3 +1,4 @@ from . import company +from . import ir_model from . import res_config 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..a9162d1eb --- /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 == '=': + domain.append(('id', 'in', model_ids)) + elif operator == '!=': + 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 index e6b1928dd..66cd222c4 100644 --- a/record_archiver/models/record_lifespan.py +++ b/record_archiver/models/record_lifespan.py @@ -43,6 +43,7 @@ class RecordLifespan(orm.Model): 'ir.model', string='Model', required=True, + domain=[('has_an_active_field', '=', True)], ), 'model': fields.related( 'model_id', 'model', From 08d64e1e8b4d19b3452dacb5623f457f10d21ff5 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 1 Jul 2015 10:43:37 +0200 Subject: [PATCH 05/14] Remove the company on record.lifespan The company was mandatory and prevented to use the module for models without company_id. Also, there is currently no use case for a different configuration for different companies. It simplifies the setup too. If a use case exists for a different setup per company, it can be reintroduced later with a optional company_id. --- record_archiver/__openerp__.py | 2 +- record_archiver/models/__init__.py | 2 - record_archiver/models/company.py | 30 -------- record_archiver/models/record_lifespan.py | 9 +-- record_archiver/models/res_config.py | 70 ------------------- .../views/record_lifespan_view.xml | 50 +++++++++++++ record_archiver/views/res_config.xml | 60 ---------------- 7 files changed, 52 insertions(+), 171 deletions(-) delete mode 100644 record_archiver/models/company.py delete mode 100644 record_archiver/models/res_config.py create mode 100644 record_archiver/views/record_lifespan_view.xml delete mode 100644 record_archiver/views/res_config.xml diff --git a/record_archiver/__openerp__.py b/record_archiver/__openerp__.py index 2ac5c486b..e24ae22f6 100644 --- a/record_archiver/__openerp__.py +++ b/record_archiver/__openerp__.py @@ -37,7 +37,7 @@ Lifespan is defined per record per company. 'complexity': "easy", # easy, normal, expert 'depends': ['base'], 'website': 'www.camptocamp.com', - 'data': ['views/res_config.xml', + 'data': ['views/record_lifespan_view.xml', 'data/cron.xml'], 'test': [], 'installable': True, diff --git a/record_archiver/models/__init__.py b/record_archiver/models/__init__.py index 8637ee398..a48e87b41 100644 --- a/record_archiver/models/__init__.py +++ b/record_archiver/models/__init__.py @@ -1,4 +1,2 @@ -from . import company from . import ir_model -from . import res_config from . import record_lifespan diff --git a/record_archiver/models/company.py b/record_archiver/models/company.py deleted file mode 100644 index cf6a9c361..000000000 --- a/record_archiver/models/company.py +++ /dev/null @@ -1,30 +0,0 @@ -# -*- 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 . -# -from openerp.osv import orm, fields - - -class Company(orm.Model): - _inherit = 'res.company' - - _columns = { - 'record_lifespan_ids': fields.one2many( - 'record.lifespan', - 'company_id', - string="Record Lifespans"), - } diff --git a/record_archiver/models/record_lifespan.py b/record_archiver/models/record_lifespan.py index 66cd222c4..510742cd4 100644 --- a/record_archiver/models/record_lifespan.py +++ b/record_archiver/models/record_lifespan.py @@ -57,16 +57,9 @@ class RecordLifespan(orm.Model): required=True, help="Number of month after which the records will be set to " "inactive based on their write date"), - 'company_id': fields.many2one( - 'res.company', - string="Company", - ondelete="cascade", - required=True), } _sql_constraints = [ - ('model_uniq', 'unique(model_id, company_id)', - "A model can only have 1 lifespan per company"), ('months_gt_0', 'check (months > 0)', "Months must be a value greater than 0"), ] @@ -90,7 +83,7 @@ class RecordLifespan(orm.Model): """ model = self.pool[lifespan.model] domain = [('write_date', '<', expiration_date), - ('company_id', '=', lifespan.company_id.id)] + ] if 'state' in model._columns: domain += [('state', 'in', ('done', 'cancel'))] return domain diff --git a/record_archiver/models/res_config.py b/record_archiver/models/res_config.py deleted file mode 100644 index 3ca7f4b9c..000000000 --- a/record_archiver/models/res_config.py +++ /dev/null @@ -1,70 +0,0 @@ -# -*- 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 openerp.osv import orm, fields - -_logger = logging.getLogger(__name__) - - -class RecordArchiverConfigSettings(orm.TransientModel): - _name = 'record.archiver.config.settings' - _inherit = 'res.config.settings' - - _columns = { - 'company_id': fields.many2one('res.company', 'Company', required=True), - 'record_lifespan_ids': fields.related( - 'company_id', 'record_lifespan_ids', - string='Record Lifespans', - type='one2many', - relation='record.lifespan'), - } - - def _default_company(self, cr, uid, context=None): - user = self.pool.get('res.users').browse(cr, uid, uid, context=context) - return user.company_id.id - - _defaults = { - 'company_id': _default_company, - } - - def create(self, cr, uid, values, context=None): - _super = super(RecordArchiverConfigSettings, self) - rec_id = _super.create(cr, uid, values, context=context) - # Hack: to avoid some nasty bug, related fields are not written upon - # record creation. - # Hence we write on those fields here. - vals = {} - for fname, field in self._columns.iteritems(): - if isinstance(field, fields.related) and fname in values: - vals[fname] = values[fname] - self.write(cr, uid, [rec_id], vals, context=context) - return id - - def onchange_company_id(self, cr, uid, ids, company_id, context=None): - # update related fields - if not company_id: - return {'value': {}} - company = self.pool.get('res.company' - ).browse(cr, uid, company_id, context=context) - lifespan_ids = [l.id for l in company.record_lifespan_ids] - values = { - 'record_lifespan_ids': lifespan_ids, - } - return {'value': values} 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. +

+
+
+ + + +
+
diff --git a/record_archiver/views/res_config.xml b/record_archiver/views/res_config.xml deleted file mode 100644 index 83d85306d..000000000 --- a/record_archiver/views/res_config.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - record archiver settings - record.archiver.config.settings - -
-
-
- - -
-
-
-
-
- - All following type of record will be harvested by the cron - based on write_date. If a record has made his time, it will be - deactivated. - -
-
- - - - - - -
-
-
- -
-
- - - Configure Records Archiver - ir.actions.act_window - record.archiver.config.settings - form - inline - - - - -
-
From 103689d17d36b10cfdd276207eede1c482e00c41 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 1 Jul 2015 14:14:31 +0200 Subject: [PATCH 06/14] Add tests an fix a condition On the search of active models --- record_archiver/models/ir_model.py | 4 +- record_archiver/tests/__init__.py | 8 +++ record_archiver/tests/test_active_search.py | 56 +++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 record_archiver/tests/__init__.py create mode 100644 record_archiver/tests/test_active_search.py diff --git a/record_archiver/models/ir_model.py b/record_archiver/models/ir_model.py index a9162d1eb..8122c43a1 100644 --- a/record_archiver/models/ir_model.py +++ b/record_archiver/models/ir_model.py @@ -54,9 +54,9 @@ class IrModel(orm.Model): load='_classic_write', context=context) model_ids = [field['model_id'] for field in active_fields] - if operator == '=': + if operator == '=' or not value: domain.append(('id', 'in', model_ids)) - elif operator == '!=': + elif operator == '!=' or value: domain.append(('id', 'not in', model_ids)) else: raise AssertionError('operator %s not allowed' % operator) diff --git a/record_archiver/tests/__init__.py b/record_archiver/tests/__init__.py new file mode 100644 index 000000000..1cc4cf843 --- /dev/null +++ b/record_archiver/tests/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +from . import test_active_search + + +checks = [ + test_active_search, +] 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)])) From 5282ef3232e410420c1e6997bf45ae1ef0064b1a Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 1 Jul 2015 14:19:37 +0200 Subject: [PATCH 07/14] Add access rights --- record_archiver/__openerp__.py | 4 +++- record_archiver/security/ir.model.access.csv | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 record_archiver/security/ir.model.access.csv diff --git a/record_archiver/__openerp__.py b/record_archiver/__openerp__.py index e24ae22f6..146313735 100644 --- a/record_archiver/__openerp__.py +++ b/record_archiver/__openerp__.py @@ -38,7 +38,9 @@ Lifespan is defined per record per company. 'depends': ['base'], 'website': 'www.camptocamp.com', 'data': ['views/record_lifespan_view.xml', - 'data/cron.xml'], + 'data/cron.xml', + 'security/ir.model.access.csv', + ], 'test': [], 'installable': True, 'auto_install': False, 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 From e527c36738459f6a6c0f0dcd975ef06b3708fb5d Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 1 Jul 2015 14:33:09 +0200 Subject: [PATCH 08/14] Add tests for the archiving of records --- record_archiver/tests/__init__.py | 2 + record_archiver/tests/test_archive.py | 68 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 record_archiver/tests/test_archive.py diff --git a/record_archiver/tests/__init__.py b/record_archiver/tests/__init__.py index 1cc4cf843..9ea217c5d 100644 --- a/record_archiver/tests/__init__.py +++ b/record_archiver/tests/__init__.py @@ -1,8 +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_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) From 7dfad2040611e709a09df5729b2af4d05cf0087e Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 1 Jul 2015 14:35:51 +0200 Subject: [PATCH 09/14] Add translation pot --- record_archiver/i18n/record_archiver.pot | 99 ++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 record_archiver/i18n/record_archiver.pot 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 "" + From d40685dddb7a4d1184392377decf4b4510105c50 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Mon, 17 Aug 2015 16:06:49 +0200 Subject: [PATCH 10/14] Use the OCA template in __openerp__.py --- record_archiver/__openerp__.py | 62 +++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/record_archiver/__openerp__.py b/record_archiver/__openerp__.py index 146313735..f76b83f4c 100644 --- a/record_archiver/__openerp__.py +++ b/record_archiver/__openerp__.py @@ -20,28 +20,80 @@ {'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` -Lifespan is defined per record per company. +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', - 'maintainer': 'Camptocamp', + 'author': 'Camptocamp,Odoo Community Association (OCA)', 'license': 'AGPL-3', 'category': 'misc', - 'complexity': "easy", # easy, normal, expert 'depends': ['base'], 'website': 'www.camptocamp.com', 'data': ['views/record_lifespan_view.xml', 'data/cron.xml', 'security/ir.model.access.csv', ], - 'test': [], 'installable': True, 'auto_install': False, } From 5bb549d9b46190be0e6500f9a7877756d586276b Mon Sep 17 00:00:00 2001 From: Yannick Vaucher Date: Wed, 6 Apr 2016 19:46:17 +0200 Subject: [PATCH 11/14] [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 @@
+ + From fa1e50990cbb69a673241094de70c515e52a2afe Mon Sep 17 00:00:00 2001 From: Artem Kostyuk Date: Fri, 5 Oct 2018 15:31:34 +0300 Subject: [PATCH 12/14] [11.0][MIG] record_archiver --- record_archiver/README.rst | 64 ++- record_archiver/__manifest__.py | 16 + record_archiver/__openerp__.py | 17 - record_archiver/data/cron.xml | 30 +- record_archiver/models/ir_model.py | 7 +- record_archiver/models/record_lifespan.py | 33 +- record_archiver/readme/CONFIGURE.rst | 4 + record_archiver/readme/CONTRIBUTORS.rst | 3 + record_archiver/readme/DESCRIPTION.rst | 4 + record_archiver/readme/ROADMAP.rst | 4 + record_archiver/readme/USAGE.rst | 2 + .../static/{src/img => description}/icon.png | Bin record_archiver/static/description/index.html | 443 ++++++++++++++++++ record_archiver/tests/__init__.py | 2 - record_archiver/tests/test_active_search.py | 5 +- record_archiver/tests/test_archive.py | 59 +-- .../views/record_lifespan_view.xml | 96 ++-- 17 files changed, 632 insertions(+), 157 deletions(-) create mode 100644 record_archiver/__manifest__.py delete mode 100644 record_archiver/__openerp__.py create mode 100644 record_archiver/readme/CONFIGURE.rst create mode 100644 record_archiver/readme/CONTRIBUTORS.rst create mode 100644 record_archiver/readme/DESCRIPTION.rst create mode 100644 record_archiver/readme/ROADMAP.rst create mode 100644 record_archiver/readme/USAGE.rst rename record_archiver/static/{src/img => description}/icon.png (100%) create mode 100644 record_archiver/static/description/index.html diff --git a/record_archiver/README.rst b/record_archiver/README.rst index 82729d096..544f47ba7 100644 --- a/record_archiver/README.rst +++ b/record_archiver/README.rst @@ -1,16 +1,40 @@ -.. 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 ================ +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! 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 ============= @@ -36,36 +60,40 @@ 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 -`_. +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 -Maintainer ----------- +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. .. 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. +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/__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/__openerp__.py b/record_archiver/__openerp__.py deleted file mode 100644 index d9ec2045b..000000000 --- a/record_archiver/__openerp__.py +++ /dev/null @@ -1,17 +0,0 @@ -# -*- coding: utf-8 -*- -# © 2015 Yannick Vaucher (Camptocamp SA) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -{'name': 'Records Archiver', - 'version': '9.0.1.0.0', - '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 index 5780f097a..6708cd5e8 100644 --- a/record_archiver/data/cron.xml +++ b/record_archiver/data/cron.xml @@ -1,19 +1,17 @@ - - + - - Records Archiver - - - 1 - months - -1 - - - - - + + Records Archiver + + + 1 + months + -1 + + + code + model._scheduler_archive_records() + - - + diff --git a/record_archiver/models/ir_model.py b/record_archiver/models/ir_model.py index e3865ab9f..be87319bb 100644 --- a/record_archiver/models/ir_model.py +++ b/record_archiver/models/ir_model.py @@ -1,7 +1,6 @@ -# -*- coding: utf-8 -*- -# © 2015 Guewen Baconnier (Camptocamp SA) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from openerp import api, fields, models +# 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): diff --git a/record_archiver/models/record_lifespan.py b/record_archiver/models/record_lifespan.py index 158ecf529..476005b93 100644 --- a/record_archiver/models/record_lifespan.py +++ b/record_archiver/models/record_lifespan.py @@ -1,25 +1,23 @@ -# -*- coding: utf-8 -*- -# © 2015-2016 Yannick Vaucher (Camptocamp SA) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# 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 openerp import api, exceptions, fields, models -from openerp.tools.translate import _ +from odoo import _, api, exceptions, fields, models _logger = logging.getLogger(__name__) 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 records), the records are deactivated. """ _name = 'record.lifespan' - _order = 'model' + _order = 'model_name' model_id = fields.Many2one( 'ir.model', @@ -27,10 +25,10 @@ class RecordLifespan(models.Model): required=True, domain=[('has_an_active_field', '=', True)], ) - model = fields.Char( + model_name = fields.Char( related='model_id.model', + readonly=True, string='Model Name', - store=True, ) months = fields.Integer( required=True, @@ -57,19 +55,19 @@ class RecordLifespan(models.Model): @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. """ model = self.env[self.model_id.model] domain = [('write_date', '<', expiration_date)] - if 'state' in model._columns: + if 'state' in model.fields_get_keys(): domain += [('state', 'in', ('done', 'cancel'))] return domain @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. The default strategy is to change the field ``active`` to False @@ -84,7 +82,7 @@ class RecordLifespan(models.Model): 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.fields_get_keys(): raise exceptions.UserError( _('Model %s has no active field') % model_name) @@ -96,19 +94,14 @@ class RecordLifespan(models.Model): 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 - self.env.cr.execute(query, (tuple(recs.ids),)) - recs.invalidate_cache() + 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 """ + """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/static/src/img/icon.png b/record_archiver/static/description/icon.png similarity index 100% rename from record_archiver/static/src/img/icon.png rename to record_archiver/static/description/icon.png 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 index 9c2e45c5e..4c88d8bc2 100644 --- a/record_archiver/tests/__init__.py +++ b/record_archiver/tests/__init__.py @@ -1,4 +1,2 @@ -# -*- coding: utf-8 -*- - 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 index ef5076aff..214522816 100644 --- a/record_archiver/tests/test_active_search.py +++ b/record_archiver/tests/test_active_search.py @@ -1,7 +1,6 @@ -# -*- coding: utf-8 -*- -# © 2015 Guewen Baconnier (Camptocamp SA) +# Copyright 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 odoo.tests.common as common class TestActiveSearch(common.TransactionCase): diff --git a/record_archiver/tests/test_archive.py b/record_archiver/tests/test_archive.py index 8e16e81d4..841b0fa00 100644 --- a/record_archiver/tests/test_archive.py +++ b/record_archiver/tests/test_archive.py @@ -1,49 +1,52 @@ -# -*- coding: utf-8 -*- -# © 2015 Guewen Baconnier (Camptocamp SA) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# Copyright 2015 Guewen Baconnier (Camptocamp SA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from datetime import datetime, timedelta -import openerp.tests.common as common +import odoo.tests.common as common -class TestArchive(common.TransactionCase): +class TestArchive(common.SavepointCase): - def setUp(self): - super(TestArchive, self).setUp() - Partner = self.env['res.partner'] - self.partner1 = Partner.create( + 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'] + cls.partner1 = Partner.create( {'name': 'test user 1'}) - self.partner2 = Partner.create( + cls.partner2 = Partner.create( {'name': 'test user 2'}) - self.partner3 = Partner.create( + cls.partner3 = Partner.create( {'name': 'test user 3'}) old_date = datetime.now() - timedelta(days=365) - self.env.cr.execute( + cls.env.cr.execute( 'UPDATE res_partner SET write_date = %s ' - 'WHERE id IN %s', (old_date, tuple([self.partner2.id, - self.partner3.id])) + 'WHERE id IN %s', (old_date, tuple([cls.partner2.id, + cls.partner3.id])) ) - self.Lifespan = self.env['record.lifespan'] - self.model_id = self.ref('base.model_res_partner') + cls.Lifespan = cls.env['record.lifespan'] + cls.model = cls.env.ref('base.model_res_partner') - @common.at_install(False) - @common.post_install(True) def test_lifespan(self): - lifespan = self.Lifespan.create( - {'model_id': self.model_id, - 'months': 3, - }) + lifespan = self.Lifespan.create({ + 'model_id': self.model.id, + 'months': 3, + }) 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): - self.Lifespan.create( - {'model_id': self.model_id, - 'months': 3, - }) + self.Lifespan.create({ + 'model_id': self.model.id, + 'months': 3, + }) self.Lifespan._scheduler_archive_records() self.assertTrue(self.partner1.active) self.assertFalse(self.partner2.active) diff --git a/record_archiver/views/record_lifespan_view.xml b/record_archiver/views/record_lifespan_view.xml index 80712977f..1f222466a 100644 --- a/record_archiver/views/record_lifespan_view.xml +++ b/record_archiver/views/record_lifespan_view.xml @@ -1,56 +1,54 @@ - - + - - record.lifespan.tree - record.lifespan - - - - - - - + + record.lifespan.tree + record.lifespan + + + + + + + - - record.lifespan.search - 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. -

-
-
+ + 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. +

+
+
- + - + -
-
+ From 6224d266c3e7b7094d122b69c8c9edf70a6ef2d6 Mon Sep 17 00:00:00 2001 From: Artem Kostyuk Date: Wed, 17 Oct 2018 12:53:35 +0300 Subject: [PATCH 13/14] [IMP] Allow dynamic state recognition on `record.lifespan` --- record_archiver/README.rst | 4 ++ record_archiver/models/record_lifespan.py | 41 +++++++++++++++++-- .../views/record_lifespan_view.xml | 1 + 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/record_archiver/README.rst b/record_archiver/README.rst index 544f47ba7..774f631a1 100644 --- a/record_archiver/README.rst +++ b/record_archiver/README.rst @@ -57,6 +57,10 @@ 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 =========== diff --git a/record_archiver/models/record_lifespan.py b/record_archiver/models/record_lifespan.py index 476005b93..b191c4671 100644 --- a/record_archiver/models/record_lifespan.py +++ b/record_archiver/models/record_lifespan.py @@ -32,8 +32,12 @@ class RecordLifespan(models.Model): ) months = fields.Integer( required=True, - help="Number of month after which the records will be set to inactive " - "based on their write date" + 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 = [ @@ -41,6 +45,29 @@ class RecordLifespan(models.Model): "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: + continue + allowed_states \ + = [x[0] for x 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([]) @@ -53,16 +80,24 @@ class RecordLifespan(models.Model): _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(',')] + @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', ('done', 'cancel'))] + domain += [('state', 'in', self._get_archive_states())] return domain @api.multi diff --git a/record_archiver/views/record_lifespan_view.xml b/record_archiver/views/record_lifespan_view.xml index 1f222466a..3fcdf3eb5 100644 --- a/record_archiver/views/record_lifespan_view.xml +++ b/record_archiver/views/record_lifespan_view.xml @@ -8,6 +8,7 @@ + From 0282562a22945cca36329b04ff8d1f5d6588ba82 Mon Sep 17 00:00:00 2001 From: Artem Kostyuk Date: Wed, 17 Oct 2018 15:59:40 +0300 Subject: [PATCH 14/14] [COV] Improve coverage --- record_archiver/models/record_lifespan.py | 10 ++- record_archiver/tests/test_archive.py | 94 ++++++++++++++++++++--- 2 files changed, 88 insertions(+), 16 deletions(-) diff --git a/record_archiver/models/record_lifespan.py b/record_archiver/models/record_lifespan.py index b191c4671..79693c559 100644 --- a/record_archiver/models/record_lifespan.py +++ b/record_archiver/models/record_lifespan.py @@ -52,10 +52,12 @@ class RecordLifespan(models.Model): continue model = self.env[lifespan.model_id.model] state_field = model.fields_get().get('state', {}) - if not state_field: + if not state_field or state_field['type'] != 'selection': continue - allowed_states \ - = [x[0] for x in state_field.get('selection', [('')])] + 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(_( @@ -85,7 +87,7 @@ class RecordLifespan(models.Model): self.ensure_one() if not self.archive_states: return ['done', 'cancel'] - return [s.strip() for s in self.archive_states.split(',')] + return [s.strip() for s in self.archive_states.split(',') if s.strip()] @api.multi def _archive_domain(self, expiration_date): diff --git a/record_archiver/tests/test_archive.py b/record_archiver/tests/test_archive.py index 841b0fa00..441bee8f7 100644 --- a/record_archiver/tests/test_archive.py +++ b/record_archiver/tests/test_archive.py @@ -2,6 +2,7 @@ # 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): @@ -17,24 +18,82 @@ class TestArchive(common.SavepointCase): tracking_disable=True, )) Partner = cls.env['res.partner'] - cls.partner1 = Partner.create( - {'name': 'test user 1'}) - cls.partner2 = Partner.create( - {'name': 'test user 2'}) - cls.partner3 = Partner.create( - {'name': 'test user 3'}) + 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, tuple([cls.partner2.id, - cls.partner3.id])) + 'WHERE id IN %s', (old_date, (cls.partner2.id, cls.partner3.id)) ) - cls.Lifespan = cls.env['record.lifespan'] - cls.model = cls.env.ref('base.model_res_partner') + 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.model.id, + 'model_id': self.partner_model.id, 'months': 3, }) lifespan.archive_records() @@ -42,9 +101,20 @@ class TestArchive(common.SavepointCase): 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.model.id, + 'model_id': self.partner_model.id, 'months': 3, }) self.Lifespan._scheduler_archive_records()