[FIX] improved the code
[odoo/odoo.git] / addons / import_base / import_framework.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21 import pprint
22 import mapper
23 import pooler
24 import tools
25 from tools.translate import _
26
27 from threading import Thread
28 import datetime
29 import logging
30 import StringIO
31 import traceback
32 pp = pprint.PrettyPrinter(indent=4)
33 _logger = logging.getLogger(__name__)
34
35
36
37 class import_framework(Thread):
38     """
39         This class should be extends,
40         get_data and get_mapping have to extends
41         get_state_map and initialize can be extended
42         for advanced purpose get_default_hook can also be extended
43         @see dummy import for a minimal exemple
44     """
45
46     """
47         for import_object, this domain will avoid to find an already existing object
48     """
49     DO_NOT_FIND_DOMAIN = [('id', '=', 0)]
50
51     #TODO don't use context to pass credential parameters
52     def __init__(self, obj, cr, uid, instance_name, module_name, email_to_notify=False, context=None):
53         Thread.__init__(self)
54         self.external_id_field = 'id'
55         self.obj = obj
56         self.cr = cr
57         self.uid = uid
58         self.instance_name = instance_name
59         self.module_name = module_name
60         self.context = context or {}
61         self.email = email_to_notify
62         self.table_list = []
63         self.initialize()
64
65     """
66         Abstract Method to be implemented in
67         the real instance
68     """
69     def initialize(self):
70         """
71             init before import
72             usually for the login
73         """
74         pass
75
76     def init_run(self):
77         """
78             call after intialize run in the thread, not in the main process
79             TO use for long initialization operation
80         """
81         pass
82
83     def get_data(self, table):
84         """
85             @return: a list of dictionaries
86                 each dictionnaries contains the list of pair  external_field_name : value
87         """
88         return [{}]
89
90     def get_link(self, from_table, ids, to_table):
91         """
92             @return: a dictionaries that contains the association between the id (from_table)
93                      and the list (to table) of id linked
94         """
95         return {}
96
97     def get_external_id(self, data):
98         """
99             @return the external id
100                 the default implementation return self.external_id_field (that has 'id') by default
101                 if the name of id field is different, you can overwrite this method or change the value
102                 of self.external_id_field
103         """
104         return data[self.external_id_field]
105
106     def get_mapping(self):
107         """
108             @return: { TABLE_NAME : {
109                 'model' : 'openerp.model.name',
110                 #if true import the table if not just resolve dependencies, use for meta package, by default => True
111                 #Not required
112                 'import' : True or False,
113                 #Not required
114                 'dependencies' : [TABLE_1, TABLE_2],
115                 #Not required
116                 'hook' : self.function_name, #get the val dict of the object, return the same val dict or False
117                 'map' : { @see mapper
118                     'openerp_field_name' : 'external_field_name', or val('external_field_name')
119                     'openerp_field_id/id' : ref(TABLE_1, 'external_id_field'), #make the mapping between the external id and the xml on the right
120                     'openerp_field2_id/id_parent' : ref(TABLE_1,'external_id_field') #indicate a self dependencies on openerp_field2_id
121                     'state' : map_val('state_equivalent_field', mapping), # use get_state_map to make the mapping between the value of the field and the value of the state
122                     'text_field' : concat('field_1', 'field_2', .., delimiter=':'), #concat the value of the list of field in one
123                     'description_field' : ppconcat('field_1', 'field_2', .., delimiter='\n\t'), #same as above but with a prettier formatting
124                     'field' : call(callable, arg1, arg2, ..), #call the function with all the value, the function should send the value : self.callable
125                     'field' : callable
126                     'field' : call(method, val('external_field') interface of method is self, val where val is the value of the field
127                     'field' : const(value) #always set this field to value
128                     + any custom mapper that you will define
129                 }
130             },
131
132             }
133         """
134         return {}
135
136     def default_hook(self, val):
137         """
138             this hook will be apply on each table that don't have hook
139             here we define the identity hook
140         """
141         return val
142
143     def _import_table(self, table):
144         data = self.get_data(table)
145         map = self.get_mapping()[table]['map']
146         hook = self.get_mapping()[table].get('hook', self.default_hook)
147         model = self.get_mapping()[table]['model']
148
149         final_data = []
150         for val in data:
151             res = hook(val)
152             if res:
153                 final_data.append(res)
154         return self._save_data(model, dict(map), final_data, table)
155
156     def _save_data(self, model, mapping, datas, table):
157         """
158             @param model: the model of the object to import
159             @param table : the external table where the data come from
160             @param mapping : definition of the mapping
161                              @see: get_mapping
162             @param datas : list of dictionnaries
163                 datas = [data_1, data_2, ..]
164                 data_i is a map external field_name => value
165                 and each data_i have a external id => in data_id['id']
166         """
167         _logger.info(' Importing %s into %s' % (table, model))
168         if not datas:
169             return (0, 'No data found')
170         mapping['id'] = 'id_new'
171         res = []
172
173
174         self_dependencies = []
175         for k in mapping.keys():
176             if '_parent' in k:
177                 self_dependencies.append((k[:-7], mapping.pop(k)))
178
179         for data in datas:
180             for k, field_name in self_dependencies:
181                 data[k] = data.get(field_name) and self._generate_xml_id(data.get(field_name), table)
182
183             data['id_new'] = self._generate_xml_id(self.get_external_id(data), table)
184             fields, values = self._fields_mapp(data, mapping, table)
185             res.append(values)
186
187         model_obj = self.obj.pool.get(model)
188         if not model_obj:
189             raise ValueError(_("%s is not a valid model name.") % model)
190         _logger.debug(_(" fields imported : ") + str(fields))
191         (p, r, warning, s) = model_obj.import_data(self.cr, self.uid, fields, res, mode='update', current_module=self.module_name, noupdate=True, context=self.context)
192         for (field, field_name) in self_dependencies:
193             self._import_self_dependencies(model_obj, field, datas)
194         return (len(res), warning)
195
196     def _import_self_dependencies(self, obj, parent_field, datas):
197         """
198             @param parent_field: the name of the field that generate a self_dependencies, we call the object referenced in this
199                 field the parent of the object
200             @param datas: a list of dictionnaries
201                 Dictionnaries need to contains
202                     id_new : the xml_id of the object
203                     field_new : the xml_id of the parent
204         """
205         fields = ['id', parent_field]
206         for data in datas:
207             if data.get(parent_field):
208                 values = [data['id_new'], data[parent_field]]
209                 obj.import_data(self.cr, self.uid, fields, [values], mode='update', current_module=self.module_name, noupdate=True, context=self.context)
210
211     def _preprocess_mapping(self, mapping):
212         """
213             Preprocess the mapping :
214             after the preprocces, everything is
215             callable in the val of the dictionary
216
217             use to allow syntaxical sugar like 'field': 'external_field'
218             instead of 'field' : value('external_field')
219         """
220         map = dict(mapping)
221         for key, value in map.items():
222             if isinstance(value, basestring):
223                 map[key] = mapper.value(value)
224             #set parent for instance of dbmapper
225             elif isinstance(value, mapper.dbmapper):
226                 value.set_parent(self)
227         return map
228
229
230     def _fields_mapp(self,dict_sugar, openerp_dict, table):
231         """
232             call all the mapper and transform data
233             to be compatible with import_data
234         """
235         fields=[]
236         data_lst = []
237         mapping = self._preprocess_mapping(openerp_dict)
238         for key,val in mapping.items():
239             if key not in fields and dict_sugar:
240                 fields.append(key)
241                 value = val(dict(dict_sugar))
242                 data_lst.append(value)
243         return fields, data_lst
244
245     def _generate_xml_id(self, name, table):
246         """
247             @param name: name of the object, has to be unique in for a given table
248             @param table : table where the record we want generate come from
249             @return: a unique xml id for record, the xml_id will be the same given the same table and same name
250                      To be used to avoid duplication of data that don't have ids
251         """
252         sugar_instance = self.instance_name
253         name = name.replace('.', '_').replace(',', '_')
254         return sugar_instance + "_" + table + "_" + name
255
256
257     """
258         Public interface of the framework
259         those function can be use in the callable function defined in the mapping
260     """
261     def xml_id_exist(self, table, external_id):
262         """
263             Check if the external id exist in the openerp database
264             in order to check if the id exist the table where it come from
265             should be provide
266             @return the xml_id generated if the external_id exist in the database or false
267         """
268         if not external_id:
269             return False
270
271         xml_id = self._generate_xml_id(external_id, table)
272         id = self.obj.pool.get('ir.model.data').search(self.cr, self.uid, [('name', '=', xml_id), ('module', '=', self.module_name)])
273         return id and xml_id or False
274
275     def name_exist(self, table, name, model):
276         """
277             Check if the object with the name exist in the openerp database
278             in order to check if the id exist the table where it come from
279             should be provide and the model of the object
280         """
281         fields = ['name']
282         data = [name]
283         return self.import_object(fields, data, model, table, name, [('name', '=', name)])
284
285     def get_mapped_id(self, table, external_id, context=None):
286         """
287             @return return the databse id linked with the external_id
288         """
289         if not external_id:
290             return False
291
292         xml_id = self._generate_xml_id(external_id, table)
293         return self.obj.pool.get('ir.model.data').get_object_reference(self.cr, self.uid, self.module_name, xml_id)[1]
294
295     def import_object_mapping(self, mapping, data, model, table, name, domain_search=False):
296         """
297             same as import_objects but instead of two list fields and data,
298             this method take a dictionnaries : external_field : value
299                             and the mapping similar to the one define in 'map' key
300             @see import_object, get_mapping
301         """
302         fields, datas = self._fields_mapp(data, mapping, table)
303         return self.import_object(fields, datas, model, table, name, domain_search)
304
305     def import_object(self, fields, data, model, table, name, domain_search=False):
306         """
307             This method will import an object in the openerp, usefull for field that is only a char in sugar and is an object in openerp
308             use import_data that will take care to create/update or do nothing with the data
309             this method return the xml_id
310
311             To be use, when you want to create an object or link if already exist
312             use DO_NOT_LINK_DOMAIN to create always a new object
313             @param fields: list of fields needed to create the object without id
314             @param data: the list of the data, in the same order as the field
315                 ex : fields = ['firstname', 'lastname'] ; data = ['John', 'Mc donalds']
316             @param model: the openerp's model of the create/update object
317             @param table: the table where data come from in sugarcrm, no need to fit the real name of openerp name, just need to be unique
318             @param unique_name: the name of the object that we want to create/update get the id
319             @param domain_search : the domain that should find the unique existing record
320
321             @return: the xml_id of the ressources
322         """
323         domain_search = not domain_search and [('name', 'ilike', name)] or domain_search
324         obj = self.obj.pool.get(model)
325         if not obj: #if the model doesn't exist
326             return False
327
328         xml_id = self._generate_xml_id(name, table)
329         xml_ref = self.mapped_id_if_exist(model, domain_search, table, name)
330         fields.append('id')
331         data.append(xml_id)
332         obj.import_data(self.cr, self.uid, fields, [data], mode='update', current_module=self.module_name, noupdate=True, context=self.context)
333         return xml_ref or xml_id
334
335
336     def mapped_id_if_exist(self, model, domain, table, name):
337         """
338             To be use when we want link with and existing object, if the object don't exist
339             just ignore.
340             @param domain : search domain to find existing record, should return a unique record
341             @param xml_id: xml_id give to the mapping
342             @param name: external_id or name of the object to create if there is no id
343             @param table: the name of the table of the object to map
344             @return : the xml_id if the record exist in the db, False otherwise
345         """
346         obj = self.obj.pool.get(model)
347         ids = obj.search(self.cr, self.uid, domain, context=self.context)
348         if ids:
349             xml_id = self._generate_xml_id(name, table)
350             ir_model_data_obj = obj.pool.get('ir.model.data')
351             id = ir_model_data_obj._update(self.cr, self.uid, model,
352                              self.module_name, {}, mode='update', xml_id=xml_id,
353                              noupdate=True, res_id=ids[0], context=self.context)
354             return xml_id
355         return False
356
357
358     def set_table_list(self, table_list):
359         """
360             Set the list of table to import, this method should be call before run
361             @param table_list: the list of external table to import
362                ['Leads', 'Opportunity']
363         """
364         self.table_list = table_list
365
366     def run(self):
367         """
368             Import all data into openerp,
369             this is the Entry point to launch the process of import
370
371
372         """
373         self.data_started = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
374         self.cr = pooler.get_db(self.cr.dbname).cursor()
375         error = False
376         result = []
377         try:
378             self.init_run()
379             imported = set() #to invoid importing 2 times the sames modules
380             for table in self.table_list:
381                 to_import = self.get_mapping()[table].get('import', True)
382                 if not table in imported:
383                     res = self._resolve_dependencies(self.get_mapping()[table].get('dependencies', []), imported)
384                     result.extend(res)
385                     if to_import:
386                         (position, warning) = self._import_table(table)
387                         result.append((table, position, warning))
388                     imported.add(table)
389             self.cr.commit()
390
391         except Exception, err:
392             sh = StringIO.StringIO()
393             traceback.print_exc(file=sh)
394             error = sh.getvalue()
395             print error
396
397
398         self.date_ended = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
399         self._send_notification_email(result, error)
400
401         self.cr.close()
402
403     def _resolve_dependencies(self, dep, imported):
404         """
405             import dependencies recursively
406             and avoid to import twice the same table
407         """
408         result = []
409         for dependency in dep:
410             if not dependency in imported:
411                 to_import = self.get_mapping()[dependency].get('import', True)
412                 res = self._resolve_dependencies(self.get_mapping()[dependency].get('dependencies', []), imported)
413                 result.extend(res)
414                 if to_import:
415                     r = self._import_table(dependency)
416                     (position, warning) = r
417                     result.append((dependency, position, warning))
418                 imported.add(dependency)
419         return result
420
421     def _send_notification_email(self, result, error):
422         if not self.email:
423             return False        
424         email_obj = self.obj.pool.get('mail.message')
425         email_id = email_obj.create(self.cr, self.uid, {
426             'email_from' : 'import@module.openerp',
427             'email_to' : self.email,
428             'body_text' : self.get_email_body(result, error),
429             'subject' : self.get_email_subject(result, error),
430             'auto_delete' : True})
431         email_obj.send(self.cr, self.uid, [email_id])
432         if error:
433             _logger.error(_("Import failed due to an unexpected error"))
434         else:
435             _logger.info(_("Import finished, notification email sended"))
436
437     def get_email_subject(self, result, error=False):
438         """
439             This method define the subject of the email send by openerp at the end of import
440             @param result: a list of tuple
441                 (table_name, number_of_record_created/updated, warning) for each table
442             @return the subject of the mail
443
444         """
445         if error:
446             return _("Data Import failed at %s due to an unexpected error") % self.date_ended
447         return _("Import of your data finished at %s") % self.date_ended
448
449     def get_email_body(self, result, error=False):
450         """
451             This method define the body of the email send by openerp at the end of import. The body is separated in two part
452             the header (@see get_body_header), and the generate body with the list of table and number of record imported.
453             If you want to keep this it's better to overwrite get_body_header
454             @param result: a list of tuple
455                 (table_name, number_of_record_created/updated, warning) for each table
456             @return the subject of the mail
457
458         """
459
460         body = _("started at %s and finished at %s \n") % (self.data_started, self.date_ended)
461         if error:
462             body += _("but failed, in consequence no data were imported to keep database consistency \n error : \n") + error
463
464         for (table, nb, warning) in result:
465             if not warning:
466                 warning = _("with no warning")
467             else:
468                 warning = _("with warning : %s") % warning
469             body += _("%s has been successfully imported from %s %s, %s \n") % (nb, self.instance_name, table, warning)
470         return self.get_body_header(result) + "\n\n" + body
471
472     def get_body_header(self, result):
473         """
474             @return the first sentences written in the mail's body
475         """
476         return _("The import of data \n instance name : %s \n") % self.instance_name
477
478
479     #TODO documentation test
480     def run_test(self):
481         back_get_data = self.get_data
482         back_get_link = self.get_link
483         back_init = self.initialize
484         self.get_data = self.get_data_test
485         self.get_link = self.get_link_test
486         self.initialize = self.intialize_test
487         self.run()
488         self.get_data = back_get_data
489         self.get_link = back_get_link
490         self.initialize = back_init
491
492     def get_data_test(self, table):
493         return [{}]
494
495     def get_link_test(self, from_table, ids, to_table):
496         return {}
497
498     def intialize_test(self):
499         pass
500
501 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: