+
+Maintainer
+----------
+
+.. image:: https://odoo-community.org/logo.png
+ :alt: Odoo Community Association
+ :target: https://odoo-community.org
+
+This module is maintained by the OCA.
+
+OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+To contribute to this module, please visit http://odoo-community.org.
+ """,
+ 'author': 'Camptocamp,Odoo Community Association (OCA)',
+ 'license': 'AGPL-3',
+ 'category': 'misc',
+ 'depends': ['base'],
+ 'website': 'www.camptocamp.com',
+ 'data': ['views/record_lifespan_view.xml',
+ 'data/cron.xml',
+ 'security/ir.model.access.csv',
+ ],
+ 'installable': True,
+ 'auto_install': False,
+ }
diff --git a/record_archiver/data/cron.xml b/record_archiver/data/cron.xml
new file mode 100644
index 000000000..5780f097a
--- /dev/null
+++ b/record_archiver/data/cron.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+ Records Archiver
+
+
+ 1
+ months
+ -1
+
+
+
+
+
+
+
+
diff --git a/record_archiver/i18n/record_archiver.pot b/record_archiver/i18n/record_archiver.pot
new file mode 100644
index 000000000..68ed9a609
--- /dev/null
+++ b/record_archiver/i18n/record_archiver.pot
@@ -0,0 +1,99 @@
+# Translation of OpenERP Server.
+# This file contains the translation of the following modules:
+# * record_archiver
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: OpenERP Server 7.0\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2015-07-01 12:35+0000\n"
+"PO-Revision-Date: 2015-07-01 12:35+0000\n"
+"Last-Translator: <>\n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: record_archiver
+#: model:ir.actions.act_window,help:record_archiver.action_record_lifespan_view
+msgid "\n"
+" Click to define a new lifespan for a type of records.\n"
+"
\n"
+" Every record of model with a lifespan will be set to inactive\n"
+" after the the defined months are elapsed. The lifespan is\n"
+" based on the last write on a record.\n"
+"
\n"
+" "
+msgstr ""
+
+#. module: record_archiver
+#: code:addons/record_archiver/models/record_lifespan.py:104
+#: code:addons/record_archiver/models/record_lifespan.py:108
+#, python-format
+msgid "Error"
+msgstr ""
+
+#. module: record_archiver
+#: field:ir.model,has_an_active_field:0
+msgid "Has an active field"
+msgstr ""
+
+#. module: record_archiver
+#: field:record.lifespan,model_id:0
+msgid "Model"
+msgstr ""
+
+#. module: record_archiver
+#: code:addons/record_archiver/models/record_lifespan.py:109
+#, python-format
+msgid "Model %s has no active field"
+msgstr ""
+
+#. module: record_archiver
+#: code:addons/record_archiver/models/record_lifespan.py:105
+#, python-format
+msgid "Model %s not found"
+msgstr ""
+
+#. module: record_archiver
+#: field:record.lifespan,model:0
+msgid "Model Name"
+msgstr ""
+
+#. module: record_archiver
+#: code:_description:0
+#: model:ir.model,name:record_archiver.model_ir_model
+#, python-format
+msgid "Models"
+msgstr ""
+
+#. module: record_archiver
+#: field:record.lifespan,months:0
+msgid "Months"
+msgstr ""
+
+#. module: record_archiver
+#: sql_constraint:record.lifespan:0
+msgid "Months must be a value greater than 0"
+msgstr ""
+
+#. module: record_archiver
+#: help:record.lifespan,months:0
+msgid "Number of month after which the records will be set to inactive based on their write date"
+msgstr ""
+
+#. module: record_archiver
+#: model:ir.actions.act_window,name:record_archiver.action_record_lifespan_view
+#: model:ir.ui.menu,name:record_archiver.menu_record_lifespan_config
+#: view:record.lifespan:0
+msgid "Records Archiver Lifespans"
+msgstr ""
+
+#. module: record_archiver
+#: code:_description:0
+#: model:ir.model,name:record_archiver.model_record_lifespan
+#, python-format
+msgid "record.lifespan"
+msgstr ""
+
diff --git a/record_archiver/models/__init__.py b/record_archiver/models/__init__.py
new file mode 100644
index 000000000..a48e87b41
--- /dev/null
+++ b/record_archiver/models/__init__.py
@@ -0,0 +1,2 @@
+from . import ir_model
+from . import record_lifespan
diff --git a/record_archiver/models/ir_model.py b/record_archiver/models/ir_model.py
new file mode 100644
index 000000000..8122c43a1
--- /dev/null
+++ b/record_archiver/models/ir_model.py
@@ -0,0 +1,73 @@
+# -*- coding: utf-8 -*-
+#
+#
+# Authors: Guewen Baconnier
+# Copyright 2015 Camptocamp SA
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+#
+
+from openerp.osv import orm, fields
+
+
+class IrModel(orm.Model):
+ _inherit = 'ir.model'
+
+ def _compute_has_an_active_field(self, cr, uid, ids, name,
+ args, context=None):
+ res = {}
+ for model_id in ids:
+ active_field_ids = self.pool['ir.model.fields'].search(
+ cr, uid,
+ [('model_id', '=', model_id),
+ ('name', '=', 'active'),
+ ],
+ limit=1,
+ context=context)
+ res[model_id] = bool(active_field_ids)
+ return res
+
+ def _search_has_an_active_field(self, cr, uid, obj, name, args,
+ context=None):
+ if not len(args):
+ return []
+ fields_model = self.pool['ir.model.fields']
+ domain = []
+ for field, operator, value in args:
+ assert field == name
+ active_field_ids = fields_model.search(
+ cr, uid, [('name', '=', 'active')], context=context)
+ active_fields = fields_model.read(cr, uid, active_field_ids,
+ fields=['model_id'],
+ load='_classic_write',
+ context=context)
+ model_ids = [field['model_id'] for field in active_fields]
+ if operator == '=' or not value:
+ domain.append(('id', 'in', model_ids))
+ elif operator == '!=' or value:
+ domain.append(('id', 'not in', model_ids))
+ else:
+ raise AssertionError('operator %s not allowed' % operator)
+ return domain
+
+ _columns = {
+ 'has_an_active_field': fields.function(
+ _compute_has_an_active_field,
+ fnct_search=_search_has_an_active_field,
+ string='Has an active field',
+ readonly=True,
+ type='boolean',
+ ),
+ }
diff --git a/record_archiver/models/record_lifespan.py b/record_archiver/models/record_lifespan.py
new file mode 100644
index 000000000..510742cd4
--- /dev/null
+++ b/record_archiver/models/record_lifespan.py
@@ -0,0 +1,133 @@
+# -*- coding: utf-8 -*-
+#
+# Author: Yannick Vaucher
+# Copyright 2015 Camptocamp SA
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+import logging
+
+from datetime import datetime
+from dateutil.relativedelta import relativedelta
+
+from openerp.osv import orm, fields
+from openerp.tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT
+from openerp.tools.translate import _
+
+_logger = logging.getLogger(__name__)
+
+
+class RecordLifespan(orm.Model):
+ """ Configure records lifespans per model
+
+ After the lifespan is expired (compared to the `write_date` of the
+ records), the records are deactivated.
+ """
+ _name = 'record.lifespan'
+
+ _order = 'model'
+
+ _columns = {
+ 'model_id': fields.many2one(
+ 'ir.model',
+ string='Model',
+ required=True,
+ domain=[('has_an_active_field', '=', True)],
+ ),
+ 'model': fields.related(
+ 'model_id', 'model',
+ string='Model Name',
+ type='char',
+ readonly=True,
+ store=True,
+ ),
+ 'months': fields.integer(
+ "Months",
+ required=True,
+ help="Number of month after which the records will be set to "
+ "inactive based on their write date"),
+ }
+
+ _sql_constraints = [
+ ('months_gt_0', 'check (months > 0)',
+ "Months must be a value greater than 0"),
+ ]
+
+ def _scheduler_archive_records(self, cr, uid, context=None):
+ lifespan_ids = self.search(cr, uid, [], context=context)
+ _logger.info('Records archiver starts archiving records')
+ for lifespan_id in lifespan_ids:
+ try:
+ self.archive_records(cr, uid, [lifespan_id], context=context)
+ except orm.except_orm as e:
+ _logger.error("Archiver error:\n%s", e[1])
+ _logger.info('Rusty Records now rest in peace')
+ return True
+
+ def _archive_domain(self, cr, uid, lifespan, expiration_date,
+ context=None):
+ """ Returns the domain used to find the records to archive.
+
+ Can be inherited to change the archived records for a model.
+ """
+ model = self.pool[lifespan.model]
+ domain = [('write_date', '<', expiration_date),
+ ]
+ if 'state' in model._columns:
+ domain += [('state', 'in', ('done', 'cancel'))]
+ return domain
+
+ def _archive_lifespan_records(self, cr, uid, lifespan, context=None):
+ """ Archive the records for a lifespan, so for a model.
+
+ Can be inherited to customize the archive strategy.
+ The default strategy is to change the field ``active`` to False
+ on the records having a ``write_date`` older than the lifespan.
+ Only done and canceled records will be deactivated.
+
+ """
+ today = datetime.today()
+ model = self.pool.get(lifespan.model)
+ if not model:
+ raise orm.except_orm(
+ _('Error'),
+ _('Model %s not found') % lifespan.model)
+ if 'active' not in model._columns:
+ raise orm.except_orm(
+ _('Error'),
+ _('Model %s has no active field') % lifespan.model)
+
+ delta = relativedelta(months=lifespan.months)
+ expiration_date = (today - delta).strftime(DATE_FORMAT)
+
+ domain = self._archive_domain(cr, uid, lifespan, expiration_date,
+ context=context)
+ rec_ids = model.search(cr, uid, domain, context=context)
+ if not rec_ids:
+ return
+
+ # use a SQL query to bypass tracking always messages on write for
+ # object inheriting mail.thread
+ query = ("UPDATE %s SET active = FALSE WHERE id in %%s"
+ ) % model._table
+ cr.execute(query, (tuple(rec_ids),))
+ _logger.info(
+ 'Archived %s %s older than %s',
+ len(rec_ids), lifespan.model, expiration_date)
+
+ def archive_records(self, cr, uid, ids, context=None):
+ """ Call the archiver for several record lifespans """
+ for lifespan in self.browse(cr, uid, ids, context=context):
+ self._archive_lifespan_records(cr, uid, lifespan, context=context)
+ return True
diff --git a/record_archiver/security/ir.model.access.csv b/record_archiver/security/ir.model.access.csv
new file mode 100644
index 000000000..73b9057af
--- /dev/null
+++ b/record_archiver/security/ir.model.access.csv
@@ -0,0 +1,2 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_record_lifespan,Record Lifespan,record_archiver.model_record_lifespan,base.group_no_one,1,1,1,1
diff --git a/record_archiver/static/src/img/icon.png b/record_archiver/static/src/img/icon.png
new file mode 100644
index 000000000..0959854c1
Binary files /dev/null and b/record_archiver/static/src/img/icon.png differ
diff --git a/record_archiver/tests/__init__.py b/record_archiver/tests/__init__.py
new file mode 100644
index 000000000..9ea217c5d
--- /dev/null
+++ b/record_archiver/tests/__init__.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+
+from . import test_active_search
+from . import test_archive
+
+
+checks = [
+ test_active_search,
+ test_archive,
+]
diff --git a/record_archiver/tests/test_active_search.py b/record_archiver/tests/test_active_search.py
new file mode 100644
index 000000000..f96bb7365
--- /dev/null
+++ b/record_archiver/tests/test_active_search.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+#
+#
+# Authors: Guewen Baconnier
+# Copyright 2015 Camptocamp SA
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+#
+
+
+import openerp.tests.common as common
+
+
+class TestActiveSearch(common.TransactionCase):
+
+ def test_model_with_active_field(self):
+ cr, uid = self.cr, self.uid
+ IrModel = self.registry('ir.model')
+ partner_model_id = IrModel.search(cr, uid,
+ [('model', '=', 'res.partner')],
+ limit=1)[0]
+ partner_model = IrModel.browse(cr, uid, partner_model_id)
+ self.assertTrue(partner_model.has_an_active_field)
+ self.assertIn(partner_model_id,
+ IrModel.search(cr, uid,
+ [('has_an_active_field', '=', True)]))
+ self.assertIn(partner_model_id,
+ IrModel.search(cr, uid,
+ [('has_an_active_field', '!=', False)]))
+
+ def test_model_without_active_field(self):
+ cr, uid = self.cr, self.uid
+ IrModel = self.registry('ir.model')
+ country_model_id = IrModel.search(cr, uid,
+ [('model', '=', 'res.country')],
+ limit=1)
+ country_model = IrModel.browse(cr, uid, country_model_id[0])
+ self.assertFalse(country_model.has_an_active_field)
+ self.assertNotIn(country_model_id,
+ IrModel.search(cr, uid,
+ [('has_an_active_field', '=', False)]))
+ self.assertNotIn(country_model_id,
+ IrModel.search(cr, uid,
+ [('has_an_active_field', '!=', True)]))
diff --git a/record_archiver/tests/test_archive.py b/record_archiver/tests/test_archive.py
new file mode 100644
index 000000000..93ba06772
--- /dev/null
+++ b/record_archiver/tests/test_archive.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+#
+#
+# Authors: Guewen Baconnier
+# Copyright 2015 Camptocamp SA
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+#
+#
+
+from datetime import datetime, timedelta
+import openerp.tests.common as common
+
+
+class TestArchive(common.TransactionCase):
+
+ def setUp(self):
+ super(TestArchive, self).setUp()
+ self.Partner = self.registry('res.partner')
+ cr, uid = self.cr, self.uid
+ self.partner1_id = self.Partner.create(cr, uid,
+ {'name': 'test user 1'})
+ self.partner2_id = self.Partner.create(cr, uid,
+ {'name': 'test user 2'})
+ self.partner3_id = self.Partner.create(cr, uid,
+ {'name': 'test user 3'})
+ old_date = datetime.now() - timedelta(days=365)
+ self.cr.execute('UPDATE res_partner SET write_date = %s '
+ 'WHERE id IN %s', (old_date, tuple([self.partner2_id,
+ self.partner3_id]))
+ )
+ self.Lifespan = self.registry('record.lifespan')
+ self.model_id = self.ref('base.model_res_partner')
+
+ def test_lifespan(self):
+ cr, uid = self.cr, self.uid
+ lifespan_id = self.Lifespan.create(
+ cr, uid,
+ {'model_id': self.model_id,
+ 'months': 3,
+ })
+ self.Lifespan.archive_records(cr, uid, [lifespan_id])
+ self.assertTrue(self.Partner.browse(cr, uid, self.partner1_id).active)
+ self.assertFalse(self.Partner.browse(cr, uid, self.partner2_id).active)
+ self.assertFalse(self.Partner.browse(cr, uid, self.partner3_id).active)
+
+ def test_scheduler(self):
+ cr, uid = self.cr, self.uid
+ self.Lifespan.create(
+ cr, uid,
+ {'model_id': self.model_id,
+ 'months': 3,
+ })
+ self.Lifespan._scheduler_archive_records(cr, uid)
+ self.assertTrue(self.Partner.browse(cr, uid, self.partner1_id).active)
+ self.assertFalse(self.Partner.browse(cr, uid, self.partner2_id).active)
+ self.assertFalse(self.Partner.browse(cr, uid, self.partner3_id).active)
diff --git a/record_archiver/views/record_lifespan_view.xml b/record_archiver/views/record_lifespan_view.xml
new file mode 100644
index 000000000..45d69a31c
--- /dev/null
+++ b/record_archiver/views/record_lifespan_view.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+ record.lifespan.tree
+ record.lifespan
+
+
+
+
+
+
+
+
+
+ record.lifespan.search
+ record.lifespan
+
+
+
+
+
+
+
+
+ Records Archiver Lifespans
+ ir.actions.act_window
+ record.lifespan
+ form
+ tree
+
+
+
+ Click to define a new lifespan for a type of records.
+
+ Every record of model with a lifespan will be set to inactive
+ after the the defined months are elapsed. The lifespan is
+ based on the last write on a record.
+
+
+
+
+
+
+
+