[ADD] Anonymization module
[odoo/odoo.git] / addons / anonymization / anonymization.py
1 # -*- encoding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>). All Rights Reserved
6 #    $Id$
7 #
8 #    This program is free software: you can redistribute it and/or modify
9 #    it under the terms of the GNU General Public License as published by
10 #    the Free Software Foundation, either version 3 of the License, or
11 #    (at your option) any later version.
12 #
13 #    This program is distributed in the hope that it will be useful,
14 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
15 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 #    GNU General Public License for more details.
17 #
18 #    You should have received a copy of the GNU General Public License
19 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
20 #
21 ##############################################################################
22
23 from lxml import etree
24 import os
25 import base64
26 try:
27     import cPickle as pickle
28 except ImportError:
29     import pickle
30 import random
31 import datetime
32
33 import netsvc
34 import pooler, tools
35 from osv import fields, osv
36 from tools.translate import _
37
38
39 FIELD_STATES = [('clear', 'Clear'), ('anonymized', 'Anonymized'), ('not_existing', 'Not Existing')]
40 ANONYMIZATION_STATES = FIELD_STATES + [('unstable', 'Unstable')]
41 ANONYMIZATION_HISTORY_STATE = [('started', 'Started'), ('done', 'Done'), ('in_exception', 'Exception occured')]
42 ANONYMIZATION_DIRECTION = [('clear -> anonymized', 'clear -> anonymized'), ('anonymized -> clear', 'anonymized -> clear')]
43
44
45 class ir_model_fields_anonymization(osv.osv):
46     _name = 'ir.model.fields.anonymization'
47     _rec_name = 'field_id'
48
49     _columns = {
50         'model_name': fields.char('Object Name', size=128, required=True),
51         'model_id': fields.many2one('ir.model', 'Object', ondelete='set null'),
52         'field_name': fields.char('Field Name', size=128, required=True),
53         'field_id': fields.many2one('ir.model.fields', 'Field', ondelete='set null'),
54         'state': fields.selection(selection=FIELD_STATES, String='State', required=True, readonly=True),
55     }
56
57     _sql_constraints = [
58         ('model_id_field_id_uniq', 'unique (model_name, field_name)', _("You cannot have two records having the same model and the same field")),
59     ]
60
61     def _get_global_state(self, cr, uid, context=None):
62         ids = self.search(cr, uid, [('state', '<>', 'not_existing')], context=context)
63         fields = self.browse(cr, uid, ids, context=context)
64         if not len(fields) or len(fields) == len([f for f in fields if f.state == 'clear']):
65             state = 'clear' # all fields are clear
66         elif len(fields) == len([f for f in fields if f.state == 'anonymized']):
67             state = 'anonymized' # all fields are anonymized
68         else:
69             state = 'unstable' # fields are mixed: this should be fixed
70         return state
71
72     def _check_write(self, cr, uid, context=None):
73         # check that the field is created from the menu and not from an database update
74         # otherwise the database update can crash:
75         if context.get('manual'):
76             global_state = self._get_global_state(cr, uid, context=context)
77             if global_state == 'anonymized':
78                 raise osv.except_osv('Error !', "The database is currently anonymized, you cannot create, modify or delete fields.")
79             elif global_state == 'unstable':
80                 msg = "The database anonymization is currently in an unstable state. Some fields are anonymized," + \
81                       " while some fields are not anonymized. You should try to solve this problem before trying to create, write or delete fields."
82                 raise osv.except_osv('Error !', msg)
83
84         return True
85
86     def _get_model_and_field_ids(self, cr, uid, vals, context=None):
87         model_and_field_ids = (False, False)
88
89         if 'field_name' in vals and vals['field_name'] and 'model_name' in vals and vals['model_name']:
90             ir_model_fields_obj = self.pool.get('ir.model.fields')
91             ir_model_obj = self.pool.get('ir.model')
92
93             model_ids = ir_model_obj.search(cr, uid, [('model', '=', vals['model_name'])], context=context)
94             if model_ids:
95                 field_ids = ir_model_fields_obj.search(cr, uid, [('name', '=', vals['field_name']), ('model_id', '=', model_ids[0])], context=context)
96                 if field_ids:
97                     field_id = field_ids[0]
98                     model_and_field_ids = (model_ids[0], field_id)
99
100         return model_and_field_ids
101
102     def create(self, cr, uid, vals, context=None):
103         # check field state: all should be clear before we can add a new field to anonymize:
104         self._check_write(cr, uid, context=context)
105
106         if 'field_name' in vals and vals['field_name'] and 'model_name' in vals and vals['model_name']:
107             vals['model_id'], vals['field_id'] = self._get_model_and_field_ids(cr, uid, vals, context=context)
108
109         # check not existing fields:
110         if not vals.get('field_id'):
111             vals['state'] = 'not_existing'
112
113         res = super(ir_model_fields_anonymization, self).create(cr, uid, vals, context=context)
114
115         return res
116
117     def write(self, cr, uid, ids, vals, context=None):
118         # check field state: all should be clear before we can modify a field:
119         if not (len(vals.keys()) == 1 and vals.get('state') == 'clear'):
120             self._check_write(cr, uid, context=context)
121
122         if 'field_name' in vals and vals['field_name'] and 'model_name' in vals and vals['model_name']:
123             vals['model_id'], vals['field_id'] = self._get_model_and_field_ids(cr, uid, vals, context=context)
124
125         # check not existing fields:
126         if 'field_id' in vals:
127             if not vals.get('field_id'):
128                 vals['state'] = 'not_existing'
129             else:
130                 global_state = self._get_global_state(cr, uid, context)
131                 if global_state != 'unstable':
132                     vals['state'] = global_state
133
134         res = super(ir_model_fields_anonymization, self).write(cr, uid, ids, vals, context=context)
135
136         return res
137
138     def unlink(self, cr, uid, ids, context=None):
139         # check field state: all should be clear before we can unlink a field:
140         self._check_write(cr, uid, context=context)
141
142         res = super(ir_model_fields_anonymization, self).unlink(cr, uid, ids, context=context)
143         return res
144
145     def onchange_model_id(self, cr, uid, ids, model_id, context=None):
146         res = {'value': {
147                     'field_name': False,
148                     'field_id': False,
149                     'model_name': False,
150               }}
151
152         if model_id:
153             ir_model_obj = self.pool.get('ir.model')
154             model_ids = ir_model_obj.search(cr, uid, [('id', '=', model_id)])
155             model_id = model_ids and model_ids[0] or None
156             model_name = model_id and ir_model_obj.browse(cr, uid, model_id).model or False
157             res['value']['model_name'] = model_name
158
159         return res
160
161     def onchange_model_name(self, cr, uid, ids, model_name, context=None):
162         res = {'value': {
163                     'field_name': False,
164                     'field_id': False,
165                     'model_id': False,
166               }}
167
168         if model_name:
169             ir_model_obj = self.pool.get('ir.model')
170             model_ids = ir_model_obj.search(cr, uid, [('model', '=', model_name)])
171             model_id = model_ids and model_ids[0] or False
172             res['value']['model_id'] = model_id
173
174         return res
175
176     def onchange_field_name(self, cr, uid, ids, field_name, model_name):
177         res = {'value': {
178                 'field_id': False,
179             }}
180
181         if field_name and model_name:
182             ir_model_fields_obj = self.pool.get('ir.model.fields')
183             field_ids = ir_model_fields_obj.search(cr, uid, [('name', '=', field_name), ('model', '=', model_name)])
184             field_id = field_ids and field_ids[0] or False
185             res['value']['field_id'] = field_id
186
187         return res
188
189     def onchange_field_id(self, cr, uid, ids, field_id, model_name):
190         res = {'value': {
191                     'field_name': False,
192               }}
193
194         if field_id:
195             ir_model_fields_obj = self.pool.get('ir.model.fields')
196             field = ir_model_fields_obj.browse(cr, uid, field_id)
197             res['value']['field_name'] = field.name
198
199         return res
200
201     _defaults = {
202         'state': lambda *a: 'clear',
203     }
204
205 ir_model_fields_anonymization()
206
207
208 class ir_model_fields_anonymization_history(osv.osv):
209     _name = 'ir.model.fields.anonymization.history'
210     _order = "date desc"
211
212     _columns = {
213         'date': fields.datetime('Date', required=True, readonly=True),
214         'field_ids': fields.many2many('ir.model.fields.anonymization', 'anonymized_field_to_history_rel', 'field_id', 'history_id', 'Fields', readonly=True),
215         'state': fields.selection(selection=ANONYMIZATION_HISTORY_STATE, string='State', required=True, readonly=True),
216         'direction': fields.selection(selection=ANONYMIZATION_DIRECTION, string='Direction', required=True, readonly=True),
217         'msg': fields.text('Message', readonly=True),
218         'filepath': fields.char(string='File path', size=256, readonly=True),
219     }
220
221 ir_model_fields_anonymization_history()
222
223
224 class ir_model_fields_anonymize_wizard(osv.osv_memory):
225     _name = 'ir.model.fields.anonymize.wizard'
226
227     def _get_state(self, cr, uid, ids, name, arg, context=None):
228         res = {}
229
230         state = self._get_state_value(cr, uid, context=None)
231         for id in ids:
232             res[id] = state
233
234         return res
235
236     def _get_summary(self, cr, uid, ids, name, arg, context=None):
237         res = {}
238         summary = self._get_summary_value(cr, uid, context)
239         for id in ids:
240             res[id] = summary
241
242         return res
243
244     _columns = {
245         'name': fields.char(size='64', string='File Name'),
246         'summary': fields.function(_get_summary, method=True, type='text', string='Summary'),
247         'file_export': fields.binary(string='Export'),
248         'file_import': fields.binary(string='Import'),
249         'state': fields.function(_get_state, method=True, string='State', type='selection', selection=ANONYMIZATION_STATES, readonly=False),
250         'msg': fields.text(string='Message'),
251     }
252
253     def _get_state_value(self, cr, uid, context=None):
254         state = self.pool.get('ir.model.fields.anonymization')._get_global_state(cr, uid, context=context)
255         return state
256
257     def _get_summary_value(self, cr, uid, context=None):
258         summary = u''
259         anon_field_obj = self.pool.get('ir.model.fields.anonymization')
260         ir_model_obj = self.pool.get('ir.model')
261         ir_model_fields_obj = self.pool.get('ir.model.fields')
262
263         anon_field_ids = anon_field_obj.search(cr, uid, [('state', '<>', 'not_existing')], context=context)
264         anon_fields = anon_field_obj.browse(cr, uid, anon_field_ids, context=context)
265
266         field_ids = [anon_field.field_id.id for anon_field in anon_fields if anon_field.field_id]
267         fields = ir_model_fields_obj.browse(cr, uid, field_ids, context=context)
268
269         fields_by_id = dict([(f.id, f) for f in fields])
270
271         for anon_field in anon_fields:
272             field = fields_by_id.get(anon_field.field_id.id)
273
274             values = {
275                 'model_name': field.model_id.name,
276                 'model_code': field.model_id.model,
277                 'field_code': field.name,
278                 'field_name': field.field_description,
279                 'state': anon_field.state,
280             }
281             summary += u" * %(model_name)s (%(model_code)s) -> %(field_name)s (%(field_code)s): state: (%(state)s)\n" % values
282
283         return summary
284
285     def default_get(self, cr, uid, fields_list, context=None):
286         res = {}
287         res['name'] = '.pickle'
288         res['summary'] = self._get_summary_value(cr, uid, context)
289         res['state'] = self._get_state_value(cr, uid, context)
290         res['msg'] = """Before executing the anonymization process, you should make a backup of your database."""
291
292         return res
293
294     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, *args, **kwargs):
295         state = self.pool.get('ir.model.fields.anonymization')._get_global_state(cr, uid, context=context)
296         step = context.get('step', 'new_window')
297
298         res = super(ir_model_fields_anonymize_wizard, self).fields_view_get(cr, uid, view_id, view_type, context, *args, **kwargs)
299
300         eview = etree.fromstring(res['arch'])
301         placeholder = eview.xpath("group[@name='placeholder1']")
302         placeholder = len(placeholder) and placeholder[0] or None
303
304         if placeholder:
305             if step == 'new_window' and state == 'clear':
306                 # clicked in the menu and the fields are not anonymized: warn the admin that backuping the db is very important
307                 placeholder.addnext(etree.Element('field', {'name': 'msg', 'colspan': '4', 'nolabel': '1'}))
308                 placeholder.addnext(etree.Element('newline'))
309                 placeholder.addnext(etree.Element('label', {'string': 'Warning'}))
310                 eview.remove(placeholder)
311             elif step == 'new_window' and state == 'anonymized':
312                 # clicked in the menu and the fields are already anonymized
313                 placeholder.addnext(etree.Element('newline'))
314                 placeholder.addnext(etree.Element('field', {'name': 'file_import', 'required': "1"}))
315                 eview.remove(placeholder)
316             elif step == 'just_anonymized':
317                 # we just ran the anonymization process, we need the file export field
318                 placeholder.addnext(etree.Element('newline'))
319                 placeholder.addnext(etree.Element('field', {'name': 'file_export'}))
320                 # we need to remove the button:
321                 buttons = eview.xpath("button")
322                 for button in buttons:
323                     eview.remove(button)
324                 # and add a message:
325                 placeholder.addnext(etree.Element('field', {'name': 'msg', 'colspan': '4', 'nolabel': '1'}))
326                 placeholder.addnext(etree.Element('newline'))
327                 placeholder.addnext(etree.Element('label', {'string': 'Result'}))
328                 # remove the placeholer:
329                 eview.remove(placeholder)
330             elif step == 'just_desanonymized':
331                 # we just reversed the anonymization process, we don't need any field
332                 # we need to remove the button
333                 buttons = eview.xpath("button")
334                 for button in buttons:
335                     eview.remove(button)
336                 # and add a message
337                 # and add a message:
338                 placeholder.addnext(etree.Element('field', {'name': 'msg', 'colspan': '4', 'nolabel': '1'}))
339                 placeholder.addnext(etree.Element('newline'))
340                 placeholder.addnext(etree.Element('label', {'string': 'Result'}))
341                 # remove the placeholer:
342                 eview.remove(placeholder)
343             else:
344                 from olilib.openerp import Terp, ppt, pst
345                 import pydb; pydb.debugger(['set listsize 40'])
346                 print
347
348                 # unstable ?
349                 raise
350
351             res['arch'] = etree.tostring(eview)
352
353         return res
354
355     def _raise_after_history_update(self, cr, uid, history_id, error_type, error_msg):
356         self.pool.get('ir.model.fields.anonymization.history').write(cr, uid, history_id, {
357             'state': 'in_exception',
358             'msg': error_msg,
359         })
360         raise osv.except_osv(error_type, error_msg)
361
362     def anonymize_database(self,cr, uid, ids, context=None):
363         """Sets the 'anonymized' state to defined fields"""
364
365         # create a new history record:
366         anonymization_history_model = self.pool.get('ir.model.fields.anonymization.history')
367
368         vals = {
369             'date': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
370             'state': 'started',
371             'direction': 'clear -> anonymized',
372         }
373         history_id = anonymization_history_model.create(cr, uid, vals)
374
375         # check that all the defined fields are in the 'clear' state
376         state = self.pool.get('ir.model.fields.anonymization')._get_global_state(cr, uid, context=context)
377         if state == 'anonymized':
378             self._raise_after_history_update(cr, uid, history_id, 'Error !', "The database is currently anonymized, you cannot anonymize it again.")
379         elif state == 'unstable':
380             msg = "The database anonymization is currently in an unstable state. Some fields are anonymized," + \
381                   " while some fields are not anonymized. You should try to solve this problem before trying to do anything."
382             self._raise_after_history_update(cr, uid, history_id, 'Error !', msg)
383
384         # do the anonymization:
385         dirpath = os.environ.get('HOME') or os.getcwd()
386         rel_filepath = 'field_anonymization_%s_%s.pickle' % (cr.dbname, history_id)
387         abs_filepath = os.path.abspath(os.path.join(dirpath, rel_filepath))
388
389         ir_model_fields_anonymization_model = self.pool.get('ir.model.fields.anonymization')
390         field_ids = ir_model_fields_anonymization_model.search(cr, uid, [('state', '<>', 'not_existing')], context=context)
391         fields = ir_model_fields_anonymization_model.browse(cr, uid, field_ids, context=context)
392
393         if not fields:
394             msg = "No fields are going to be anonymized."
395             self._raise_after_history_update(cr, uid, history_id, 'Error !', msg)
396
397         data = []
398
399         for field in fields:
400             model_name = field.model_id.model
401             field_name = field.field_id.name
402             field_type = field.field_id.ttype
403             table_name = self.pool.get(model_name)._table
404
405             # get the current value
406             sql = "select id, %s from %s" % (field_name, table_name)
407             cr.execute(sql)
408             records = cr.dictfetchall()
409             for record in records:
410                 data.append({"model_id": model_name, "field_id": field_name, "id": record['id'], "value": record[field_name]})
411
412                 # anonymize the value:
413                 anonymized_value = None
414
415                 sid = str(record['id'])
416                 if field_type == 'char':
417                     anonymized_value = 'xxx'+sid
418                 elif field_type == 'selection':
419                     anonymized_value = 'xxx'+sid
420                 elif field_type == 'text':
421                     anonymized_value = 'xxx'+sid
422                 elif field_type == 'boolean':
423                     anonymized_value = random.choice([True, False])
424                 elif field_type == 'date':
425                     anonymized_value = '2011-11-11'
426                 elif field_type == 'datetime':
427                     anonymized_value = '2011-11-11 11:11:11'
428                 elif field_type == 'float':
429                     if record[field_name] > 0:
430                         anonymized_value = 1.0
431                     elif record[field_name] < 0:
432                         anonymized_value = -1.0
433                     else:
434                         anonymized_value = 0.0
435                 elif field_type == 'integer':
436                     anonymized_value = 1
437                 elif field_type in ['binary', 'many2many', 'many2one', 'one2many', 'reference']: # cannot anonymize these kind of fields
438                     msg = "Cannot anonymize fields of these types: binary, many2many, many2one, one2many, reference"
439                     self._raise_after_history_update(cr, uid, history_id, 'Error !', msg)
440
441                 if anonymized_value is None:
442                     self._raise_after_history_update(cr, uid, history_id, 'Error !', "Anonymized value is None. This cannot happens.")
443
444                 sql = "update %(table)s set %(field)s = %%(anonymized_value)s where id = %%(id)s" % {
445                     'table': table_name,
446                     'field': field_name,
447                 }
448                 cr.execute(sql, {
449                     'anonymized_value': anonymized_value,
450                     'id': record['id']
451                 })
452
453         # save pickle:
454         fn = open(abs_filepath, 'w')
455         pickle.dump(data, fn, pickle.HIGHEST_PROTOCOL)
456
457         # update the anonymization fields:
458         values = {
459             'state': 'anonymized',
460         }
461         res = ir_model_fields_anonymization_model.write(cr, uid, field_ids, values, context=context)
462
463         # add a result message in the wizard:
464         msgs = ["Anonymization successful.",
465                "",
466                "Don't forget to save the resulting file to a safe place because you will not be able to revert the anonymization without this file.",
467                "",
468                "This file is also stored in the %s directory. The absolute file path is: %s",
469               ]
470         msg = '\n'.join(msgs) % (dirpath, abs_filepath)
471
472         fn = open(abs_filepath, 'r')
473
474         self.write(cr, uid, ids, {
475             'msg': msg,
476             'file_export': base64.encodestring(fn.read()),
477         })
478         fn.close()
479
480         # update the history record:
481         anonymization_history_model.write(cr, uid, history_id, {
482             'field_ids': [[6, 0, field_ids]],
483             'msg': msg,
484             'filepath': abs_filepath,
485             'state': 'done',
486         })
487
488         # handle the view:
489         view_id = self._id_get(cr, uid, 'ir.ui.view', 'view_ir_model_fields_anonymize_wizard_form', 'anonymization')
490
491         return {
492                 'res_id': ids[0],
493                 'view_id': [view_id],
494                 'view_type': 'form',
495                 "view_mode": 'form',
496                 'res_model': 'ir.model.fields.anonymize.wizard',
497                 'type': 'ir.actions.act_window',
498                 'context': {'step': 'just_anonymized'},
499                 'target':'new',
500         }
501
502     def reverse_anonymize_database(self,cr, uid, ids, context=None):
503         """Set the 'clear' state to defined fields"""
504
505         ir_model_fields_anonymization_model = self.pool.get('ir.model.fields.anonymization')
506         anonymization_history_model = self.pool.get('ir.model.fields.anonymization.history')
507
508         # create a new history record:
509         vals = {
510             'date': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
511             'state': 'started',
512             'direction': 'anonymized -> clear',
513         }
514         history_id = anonymization_history_model.create(cr, uid, vals)
515
516         # check that all the defined fields are in the 'anonymized' state
517         state = ir_model_fields_anonymization_model._get_global_state(cr, uid, context=context)
518         if state == 'clear':
519             raise osv.except_osv('Error !', "The database is not currently anonymized, you cannot reverse the anonymization.")
520         elif state == 'unstable':
521             msg = "The database anonymization is currently in an unstable state. Some fields are anonymized," + \
522                   " while some fields are not anonymized. You should try to solve this problem before trying to do anything."
523             raise osv.except_osv('Error !', msg)
524
525         wizards = self.browse(cr, uid, ids, context=context)
526         for wizard in wizards:
527             if not wizard.file_import:
528                 msg = "The anonymization export file was not supplied. It is not possible to reverse the anonymization process without this file."
529                 self._raise_after_history_update(cr, uid, history_id, 'Error !', msg)
530
531             # reverse the anonymization:
532             # load the pickle file content into a data structure:
533             data = pickle.loads(base64.decodestring(wizard.file_import))
534
535             for line in data:
536                 table_name = self.pool.get(line['model_id'])._table
537                 sql = "update %(table)s set %(field)s = %%(value)s where id = %%(id)s" % {
538                     'table': table_name,
539                     'field': line['field_id'],
540                 }
541                 cr.execute(sql, {
542                     'value': line['value'],
543                     'id': line['id']
544                 })
545
546             # update the anonymization fields:
547             ir_model_fields_anonymization_model = self.pool.get('ir.model.fields.anonymization')
548             field_ids = ir_model_fields_anonymization_model.search(cr, uid, [('state', '<>', 'not_existing')], context=context)
549             values = {
550                 'state': 'clear',
551             }
552             res = ir_model_fields_anonymization_model.write(cr, uid, field_ids, values, context=context)
553
554             # add a result message in the wizard:
555             msg = '\n'.join(["Successfully reversed the anonymization.",
556                              "",
557                             ])
558
559             self.write(cr, uid, ids, {'msg': msg})
560
561             # update the history record:
562             anonymization_history_model.write(cr, uid, history_id, {
563                 'field_ids': [[6, 0, field_ids]],
564                 'msg': msg,
565                 'filepath': False,
566                 'state': 'done',
567             })
568
569             # handle the view:
570             view_id = self._id_get(cr, uid, 'ir.ui.view', 'view_ir_model_fields_anonymize_wizard_form', 'anonymization')
571
572             return {
573                     'res_id': ids[0],
574                     'view_id': [view_id],
575                     'view_type': 'form',
576                     "view_mode": 'form',
577                     'res_model': 'ir.model.fields.anonymize.wizard',
578                     'type': 'ir.actions.act_window',
579                     'context': {'step': 'just_desanonymized'},
580                     'target':'new',
581             }
582
583     def _id_get(self, cr, uid, model, id_str, mod):
584         if '.' in id_str:
585             mod, id_str = id_str.split('.')
586         try:
587             idn = self.pool.get('ir.model.data')._get_id(cr, uid, mod, id_str)
588             res = int(self.pool.get('ir.model.data').read(cr, uid, [idn], ['res_id'])[0]['res_id'])
589         except Exception, e:
590             res = None
591         return res
592
593 ir_model_fields_anonymize_wizard()
594