[IMP] review of contact import
[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
26
27 from threading import Thread
28 import datetime
29 import logging
30 pp = pprint.PrettyPrinter(indent=4)
31
32
33
34
35 class import_framework(Thread):
36     """
37         This class should be extends,
38         get_data and get_mapping have to extends
39         get_state_map and initialize can be extended
40         for advanced purpose get_default_hook can also be extended
41         @see dummy import for a minimal exemple
42     """
43     
44     """
45         for import_object, this domain will avoid to find an already existing object
46     """
47     DO_NOT_FIND_DOMAIN = [('id', '=', 0)]
48   
49     def __init__(self, obj, cr, uid, instance_name, module_name, email_to_notify=False, context=None):
50         Thread.__init__(self)
51         self.obj = obj
52         self.cr = cr
53         self.uid = uid
54         self.instance_name = instance_name
55         self.module_name = module_name
56         self.context = context or {}
57         self.email = email_to_notify
58         self.table_list = []
59         self.initialize()
60         
61         
62       
63     """
64         Abstract Method to be implemented in 
65         the real instance
66     """  
67     def initialize(self):
68         """
69             init before import
70             usually for the login
71         """
72         pass
73     
74     def get_data(self, table):
75         """
76             @return: a list of dictionaries
77                 each dictionnaries contains the list of pair  external_field_name : value 
78         """
79         return [{}]
80     
81     def get_mapping(self):
82         """
83             @return: { TABLE_NAME : {
84                 'model' : 'openerp.model.name',
85                 #if true import the table if not just resolve dependencies, use for meta package, by default => True  
86                 #Not required
87                 'import' : True or False, 
88                 #Not required
89                 'dependencies' : [TABLE_1, TABLE_2],
90                 #Not required
91                 'hook' : self.function_name, #get the val dict of the object, return the same val dict or False
92                 'map' : { @see mapper
93                     'openerp_field_name' : 'external_field_name', or val('external_field_name')
94                     'openerp_field_id/id' : ref(TABLE_1, 'external_id_field'), #make the mapping between the external id and the xml on the right
95                     'openerp_field2_id/id_parent' : ref(TABLE_1,'external_id_field') #indicate a self dependencies on openerp_field2_id
96                     '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
97                     'text_field' : concat('field_1', 'field_2', .., delimiter=':'), #concat the value of the list of field in one
98                     'description_field' : ppconcat('field_1', 'field_2', .., delimiter='\n\t'), #same as above but with a prettier formatting
99                     'field' : call(callable, arg1, arg2, ..), #call the function with all the value, the function should send the value : self.callable
100                     'field' : callable
101                     'field' : call(method, val('external_field') interface of method is self, val where val is the value of the field
102                     'field' : const(value) #always set this field to value
103                     + any custom mapper that you will define
104                 } 
105             },
106             
107             } 
108         """
109         return {}
110     
111     def default_hook(self, val):
112         """
113             this hook will be apply on each table that don't have hook
114             here we define the identity hook
115         """
116         return val
117         
118     def _import_table(self, table):
119         data = self.get_data(table)
120         map = self.get_mapping()[table]['map']
121         hook = self.get_mapping()[table].get('hook', self.default_hook)
122         model = self.get_mapping()[table]['model']
123         
124         final_data = []
125         for val in data:
126             res = hook(val)
127             if res:
128                 final_data.append(res)
129         return self._save_data(model, dict(map), final_data, table)
130         
131     def _save_data(self, model, mapping, datas, table):
132         """
133             @param model: the model of the object to import
134             @param table : the external table where the data come from 
135             @param mapping : definition of the mapping
136                              @see: get_mapping
137             @param datas : list of dictionnaries 
138                 datas = [data_1, data_2, ..]
139                 data_i is a map external field_name => value
140                 and each data_i have a external id => in data_id['id']
141         """
142         if not datas:
143             return (0, 'No data in this table')
144         mapping['id'] = 'id_new'
145         res = []
146         
147         
148         self_dependencies = []
149         for k in mapping.keys():
150             if '_parent' in k:
151                 self_dependencies.append((k[:-7], mapping.pop(k)))
152         
153         for data in datas:
154             for k, field_name in self_dependencies:
155                 data[k] = data.get(field_name) and self._generate_xml_id(data.get(field_name), table)
156                     
157             data['id_new'] = self._generate_xml_id(data['id'], table)
158             fields, values = self._fields_mapp(data, mapping, table)
159             res.append(values)
160         
161         model_obj = self.obj.pool.get(model)
162         if not model_obj:
163             raise ValueError("%s is not a valid model name" % model)
164         
165         (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)
166         for (field, field_name) in self_dependencies:
167             self._import_self_dependencies(model_obj, field, datas)
168         return (len(res), warning)
169             
170     def _import_self_dependencies(self, obj, parent_field, datas):
171         """
172             @param parent_field: the name of the field that generate a self_dependencies, we call the object referenced in this
173                 field the parent of the object
174             @param datas: a list of dictionnaries
175                 Dictionnaries need to contains 
176                     id_new : the xml_id of the object
177                     field_new : the xml_id of the parent
178         """
179         fields = ['id', parent_field]
180         for data in datas:
181             if data.get(parent_field):
182                 values = [data['id_new'], data[parent_field]]
183                 obj.import_data(self.cr, self.uid, fields, [values], mode='update', current_module=self.module_name, noupdate=True, context=self.context) 
184     
185     def _preprocess_mapping(self, mapping):
186         """
187             Preprocess the mapping :
188             after the preprocces, everything is 
189             callable in the val of the dictionary
190             
191             use to allow syntaxical sugar like 'field': 'external_field'
192             instead of 'field' : value('external_field')
193         """
194         map = dict(mapping)
195         for key, value in map.items():
196             if isinstance(value, basestring):
197                 map[key] = mapper.value(value)
198             #set parent for instance of dbmapper
199             elif isinstance(value, mapper.dbmapper): 
200                 value.set_parent(self)
201         return map   
202             
203             
204     def _fields_mapp(self,dict_sugar, openerp_dict, table):
205         """
206             call all the mapper and transform data 
207             to be compatible with import_data
208         """
209         fields=[]
210         data_lst = []
211         mapping = self._preprocess_mapping(openerp_dict)
212         for key,val in mapping.items():
213             if key not in fields and dict_sugar:
214                 fields.append(key)
215                 value = val(dict(dict_sugar))
216                 data_lst.append(value)
217         return fields, data_lst
218     
219     def _generate_xml_id(self, name, table):
220         """
221             @param name: name of the object, has to be unique in for a given table
222             @param table : table where the record we want generate come from
223             @return: a unique xml id for record, the xml_id will be the same given the same table and same name
224                      To be used to avoid duplication of data that don't have ids
225         """
226         sugar_instance = self.instance_name
227         name = name.replace('.', '_').replace(',', '_')
228         return sugar_instance + "_" + table + "_" + name
229     
230     
231     """
232         Public interface of the framework
233         those function can be use in the callable function defined in the mapping
234     """
235     def xml_id_exist(self, table, external_id):
236         """
237             Check if the external id exist in the openerp database
238             in order to check if the id exist the table where it come from
239             should be provide
240             @return the xml_id generated if the external_id exist in the database or false
241         """
242         if not external_id:
243             return False
244         
245         xml_id = self._generate_xml_id(external_id, table)
246         id = self.obj.pool.get('ir.model.data').search(self.cr, self.uid, [('name', '=', xml_id), ('module', '=', self.module_name)])
247         return id and xml_id or False
248     
249     def name_exist(self, table, name, model):
250         """
251             Check if the object with the name exist in the openerp database
252             in order to check if the id exist the table where it come from
253             should be provide and the model of the object
254         """
255         fields = ['name']
256         data = [name]
257         return self.import_object(fields, data, model, table, name, [('name', '=', name)])
258     
259     def get_mapped_id(self, table, external_id, context=None):
260         """
261             @return return the databse id linked with the external_id
262         """
263         if not external_id:
264             return False
265     
266         xml_id = self._generate_xml_id(external_id, table)
267         return self.obj.pool.get('ir.model.data').get_object_reference(self.cr, self.uid, self.module_name, xml_id)[1]
268     
269     def import_object_mapping(self, mapping, data, model, table, name, domain_search=False):
270         """
271             same as import_objects but instead of two list fields and data, 
272             this method take a dictionnaries : external_field : value 
273                             and the mapping similar to the one define in 'map' key
274             @see import_object, get_mapping
275         """
276         fields, datas = self._fields_mapp(data, mapping, table)
277         return self.import_object(fields, datas, model, table, name, domain_search)
278
279     def import_object(self, fields, data, model, table, name, domain_search=False):
280         """
281             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
282             use import_data that will take care to create/update or do nothing with the data
283             this method return the xml_id
284             
285             To be use, when you want to create an object or link if already exist
286             use DO_NOT_LINK_DOMAIN to create always a new object
287             @param fields: list of fields needed to create the object without id
288             @param data: the list of the data, in the same order as the field
289                 ex : fields = ['firstname', 'lastname'] ; data = ['John', 'Mc donalds']
290             @param model: the openerp's model of the create/update object
291             @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
292             @param unique_name: the name of the object that we want to create/update get the id
293             @param domain_search : the domain that should find the unique existing record
294             
295             @return: the xml_id of the ressources
296         """
297         domain_search = not domain_search and [('name', 'ilike', name)] or domain_search
298         obj = self.obj.pool.get(model)
299         xml_id = self._generate_xml_id(name, table)
300         xml_ref = self.mapped_id_if_exist(model, domain_search, table, name)
301         fields.append('id')
302         data.append(xml_id)
303         obj.import_data(self.cr, self.uid, fields, [data], mode='update', current_module=self.module_name, noupdate=True, context=self.context)
304         return xml_ref or xml_id
305     
306
307     def mapped_id_if_exist(self, model, domain, table, name):
308         """
309             To be use when we want link with and existing object, if the object don't exist
310             just ignore.
311             @param domain : search domain to find existing record, should return a unique record
312             @param xml_id: xml_id give to the mapping
313             @param name: external_id or name of the object to create if there is no id
314             @param table: the name of the table of the object to map
315             @return : the xml_id if the record exist in the db, False otherwise
316         """
317         obj = self.obj.pool.get(model)
318         ids = obj.search(self.cr, self.uid, domain, context=self.context)
319         if ids:
320             xml_id = self._generate_xml_id(name, table)
321             ir_model_data_obj = obj.pool.get('ir.model.data')
322             id = ir_model_data_obj._update(self.cr, self.uid, model,
323                              self.module_name, {}, mode='update', xml_id=xml_id,
324                              noupdate=True, res_id=ids[0], context=self.context)
325             return xml_id
326         return False
327     
328     
329     def set_table_list(self, table_list):
330         """
331             Set the list of table to import, this method should be call before run
332             @param table_list: the list of external table to import
333                ['Leads', 'Opportunity']
334         """
335         self.table_list = table_list
336     
337     def run(self):
338         """
339             Import all data into openerp, 
340             this is the Entry point to launch the process of import
341             
342         
343         """
344         self.data_started = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
345         self.cr = pooler.get_db(self.cr.dbname).cursor()
346         try:
347             result = []
348             imported = set() #to invoid importing 2 times the sames modules
349             for table in self.table_list:
350                 to_import = self.get_mapping()[table].get('import', True)
351                 if not table in imported:
352                     res = self._resolve_dependencies(self.get_mapping()[table].get('dependencies', []), imported)
353                     result.extend(res)
354                     if to_import:
355                         (position, warning) = self._import_table(table)
356                         result.append((table, position, warning))
357                     imported.add(table)
358             self.cr.commit()
359             
360         finally:
361             self.cr.close()
362         self.date_ended = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
363         self._send_notification_email(result)
364         
365         
366     def _resolve_dependencies(self, dep, imported):
367         """ 
368             import dependencies recursively
369             and avoid to import twice the same table
370         """
371         result = []
372         for dependency in dep:
373             if not dependency in imported:
374                 to_import = self.get_mapping()[dependency].get('import', True)
375                 res = self._resolve_dependencies(self.get_mapping()[dependency].get('dependencies', []), imported)
376                 result.extend(res)
377                 if to_import:
378                     r = self._import_table(dependency)
379                     (position, warning) = r
380                     result.append((dependency, position, warning))
381                 imported.add(dependency)
382         return result
383                 
384     def _send_notification_email(self, result):
385         if not self.email:
386             return               
387         tools.email_send(
388                 'import_sugarcrm@module.openerp',
389                 self.email,
390                 self.get_email_subject(result),
391                 self.get_email_body(result),
392             )
393         logger = logging.getLogger('import_sugarcam')
394         logger.info("Import finished, notification email sended")
395     
396     def get_email_subject(self, result):
397         """
398             This method define the subject of the email send by openerp at the end of import
399             @param result: a list of tuple 
400                 (table_name, number_of_record_created/updated, warning) for each table
401             @return the subject of the mail
402         
403         """
404         return "Import of your data finished at %s" % self.date_ended
405     
406     def get_email_body(self, result):
407         """
408             This method define the body of the email send by openerp at the end of import. The body is separated in two part
409             the header (@see get_body_header), and the generate body with the list of table and number of record imported.
410             If you want to keep this it's better to overwrite get_body_header
411             @param result: a list of tuple 
412                 (table_name, number_of_record_created/updated, warning) for each table
413             @return the subject of the mail
414         
415         """
416         
417         body = "started at %s and finished at %s \n" % (self.data_started, self.date_ended)
418         for (table, nb, warning) in result:
419             if not warning:
420                 warning = "with no warning"
421             else:
422                 warning = "with warning : %s" % warning
423             body += "%s records were imported from table %s, %s \n" % (nb, table, warning)
424         return self.get_body_header(result) + "\n\n" + body
425     
426     def get_body_header(self, result):
427         """
428             @return the first sentences written in the mail's body
429         """
430         return "The import of data \n instance name : %s \n" % self.instance_name
431     
432 #For example of use see import_sugarcrm