1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Business Applications
5 # Copyright (c) 2011-2012 OpenERP S.A. <http://openerp.com>
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.
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.
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/>.
20 ##############################################################################
24 import simplejson as json
31 import openerp.release as release
32 from openerp.osv import osv, fields
33 from openerp.tools.translate import _
34 from openerp.tools.safe_eval import safe_eval as eval
35 _logger = logging.getLogger(__name__)
37 EXTERNAL_ID_PATTERN = re.compile(r'^([^.:]+)(?::([^.]+))?\.(\S+)$')
38 EDI_VIEW_WEB_URL = '%s/edi/view?db=%s&token=%s'
39 EDI_PROTOCOL_VERSION = 1 # arbitrary ever-increasing version number
40 EDI_GENERATOR = 'OpenERP ' + release.major_version
41 EDI_GENERATOR_VERSION = release.version_info
43 def split_external_id(ext_id):
44 match = EXTERNAL_ID_PATTERN.match(ext_id)
46 _("'%s' is an invalid external ID") % (ext_id)
47 return {'module': match.group(1),
48 'db_uuid': match.group(2),
50 'full': match.group(0)}
52 def safe_unique_id(database_id, model, record_id):
53 """Generate a unique string to represent a (database_uuid,model,record_id) pair
54 without being too long, and with a very low probability of collisions.
56 msg = "%s-%s-%s-%s" % (time.time(), database_id, model, record_id)
57 digest = hashlib.sha1(msg).digest()
58 # fold the sha1 20 bytes digest to 9 bytes
59 digest = ''.join(chr(ord(x) ^ ord(y)) for (x,y) in zip(digest[:9], digest[9:-2]))
60 # b64-encode the 9-bytes folded digest to a reasonable 12 chars ASCII ID
61 digest = base64.urlsafe_b64encode(digest)
62 return '%s-%s' % (model.replace('.','_'), digest)
64 def last_update_for(record):
65 """Returns the last update timestamp for the given record,
66 if available, otherwise False
68 if record._model._log_access:
69 record_log = record.perm_read()[0]
70 return record_log.get('write_date') or record_log.get('create_date') or False
74 class edi(osv.AbstractModel):
76 _description = 'EDI Subsystem'
78 def new_edi_token(self, cr, uid, record):
79 """Return a new, random unique token to identify this model record,
80 and to be used as token when exporting it as an EDI document.
82 :param browse_record record: model record for which a token is needed
84 db_uuid = self.pool.get('ir.config_parameter').get_param(cr, uid, 'database.uuid')
85 edi_token = hashlib.sha256('%s-%s-%s-%s' % (time.time(), db_uuid, record._name, record.id)).hexdigest()
88 def serialize(self, edi_documents):
89 """Serialize the given EDI document structures (Python dicts holding EDI data),
90 using JSON serialization.
92 :param [dict] edi_documents: list of EDI document structures to serialize
93 :return: UTF-8 encoded string containing the serialized document
95 serialized_list = json.dumps(edi_documents)
96 return serialized_list
98 def generate_edi(self, cr, uid, records, context=None):
99 """Generates a final EDI document containing the EDI serialization
100 of the given records, which should all be instances of a Model
101 that has the :meth:`~.edi` mixin. The document is not saved in the
104 :param list(browse_record) records: records to export as EDI
105 :return: UTF-8 encoded string containing the serialized records
108 for record in records:
109 record_model = record._model
110 edi_list += record_model.edi_export(cr, uid, [record], context=context)
111 return self.serialize(edi_list)
113 def load_edi(self, cr, uid, edi_documents, context=None):
114 """Import the given EDI document structures into the system, using
115 :meth:`~.import_edi`.
117 :param edi_documents: list of Python dicts containing the deserialized
118 version of EDI documents
119 :return: list of (model, id, action) tuple containing the model and database ID
120 of all records that were imported in the system, plus a suggested
121 action definition dict for displaying each document.
123 ir_module = self.pool.get('ir.module.module')
125 for edi_document in edi_documents:
126 module = edi_document.get('__import_module') or edi_document.get('__module')
127 assert module, 'a `__module` or `__import_module` attribute is required in each EDI document.'
128 if module != 'base' and not ir_module.search(cr, uid, [('name','=',module),('state','=','installed')]):
129 raise osv.except_osv(_('Missing Application.'),
130 _("The document you are trying to import requires the OpenERP `%s` application. "
131 "You can install it by connecting as the administrator and opening the configuration assistant.")%(module,))
132 model = edi_document.get('__import_model') or edi_document.get('__model')
133 assert model, 'a `__model` or `__import_model` attribute is required in each EDI document.'
134 assert model in self.pool, 'model `%s` cannot be found, despite module `%s` being available - '\
135 'this EDI document seems invalid or unsupported.' % (model,module)
136 model_obj = self.pool[model]
137 record_id = model_obj.edi_import(cr, uid, edi_document, context=context)
138 record_action = model_obj._edi_record_display_action(cr, uid, record_id, context=context)
139 res.append((model, record_id, record_action))
142 def deserialize(self, edi_documents_string):
143 """Return deserialized version of the given EDI Document string.
145 :param str|unicode edi_documents_string: UTF-8 string (or unicode) containing
146 JSON-serialized EDI document(s)
147 :return: Python object representing the EDI document(s) (usually a list of dicts)
149 return json.loads(edi_documents_string)
151 def import_edi(self, cr, uid, edi_document=None, edi_url=None, context=None):
152 """Import a JSON serialized EDI Document string into the system, first retrieving it
153 from the given ``edi_url`` if provided.
155 :param str|unicode edi: UTF-8 string or unicode containing JSON-serialized
156 EDI Document to import. Must not be provided if
157 ``edi_url`` is given.
158 :param str|unicode edi_url: URL where the EDI document (same format as ``edi``)
159 may be retrieved, without authentication.
162 assert not edi_document, 'edi must not be provided if edi_url is given.'
163 edi_document = urllib2.urlopen(edi_url).read()
164 assert edi_document, 'EDI Document is empty!'
165 edi_documents = self.deserialize(edi_document)
166 return self.load_edi(cr, uid, edi_documents, context=context)
169 class EDIMixin(object):
170 """Mixin class for Model objects that want be exposed as EDI documents.
171 Classes that inherit from this mixin class should override the
172 ``edi_import()`` and ``edi_export()`` methods to implement their
173 specific behavior, based on the primitives provided by this mixin."""
175 def _edi_requires_attributes(self, attributes, edi):
176 model_name = edi.get('__imported_model') or edi.get('__model') or self._name
177 for attribute in attributes:
178 assert edi.get(attribute),\
179 'Attribute `%s` is required in %s EDI documents.' % (attribute, model_name)
181 # private method, not RPC-exposed as it creates ir.model.data entries as
182 # SUPERUSER based on its parameters
183 def _edi_external_id(self, cr, uid, record, existing_id=None, existing_module=None,
185 """Generate/Retrieve unique external ID for ``record``.
186 Each EDI record and each relationship attribute in it is identified by a
187 unique external ID, which includes the database's UUID, as a way to
188 refer to any record within any OpenERP instance, without conflict.
190 For OpenERP records that have an existing "External ID" (i.e. an entry in
191 ir.model.data), the EDI unique identifier for this record will be made of
192 "%s:%s:%s" % (module, database UUID, ir.model.data ID). The database's
193 UUID MUST NOT contain a colon characters (this is guaranteed by the
196 For records that have no existing ir.model.data entry, a new one will be
197 created during the EDI export. It is recommended that the generated external ID
198 contains a readable reference to the record model, plus a unique value that
199 hides the database ID. If ``existing_id`` is provided (because it came from
200 an import), it will be used instead of generating a new one.
201 If ``existing_module`` is provided (because it came from
202 an import), it will be used instead of using local values.
204 :param browse_record record: any browse_record needing an EDI external ID
205 :param string existing_id: optional existing external ID value, usually coming
206 from a just-imported EDI record, to be used instead
207 of generating a new one
208 :param string existing_module: optional existing module name, usually in the
209 format ``module:db_uuid`` and coming from a
210 just-imported EDI record, to be used instead
212 :return: the full unique External ID to use for record
214 ir_model_data = self.pool.get('ir.model.data')
215 db_uuid = self.pool.get('ir.config_parameter').get_param(cr, uid, 'database.uuid')
216 ext_id = record.get_external_id()[record.id]
218 ext_id = existing_id or safe_unique_id(db_uuid, record._name, record.id)
219 # ID is unique cross-db thanks to db_uuid (already included in existing_module)
220 module = existing_module or "%s:%s" % (record._original_module, db_uuid)
221 _logger.debug("%s: Generating new external ID `%s.%s` for %r.", self._name,
222 module, ext_id, record)
223 ir_model_data.create(cr, openerp.SUPERUSER_ID,
225 'model': record._name,
227 'res_id': record.id})
229 module, ext_id = ext_id.split('.')
230 if not ':' in module:
231 # this record was not previously EDI-imported
232 if not module == record._original_module:
233 # this could happen for data records defined in a module that depends
234 # on the module that owns the model, e.g. purchase defines
235 # product.pricelist records.
236 _logger.debug('Mismatching module: expected %s, got %s, for %s.',
237 module, record._original_module, record)
238 # ID is unique cross-db thanks to db_uuid
239 module = "%s:%s" % (module, db_uuid)
241 return '%s.%s' % (module, ext_id)
243 def _edi_record_display_action(self, cr, uid, id, context=None):
244 """Returns an appropriate action definition dict for displaying
245 the record with ID ``rec_id``.
247 :param int id: database ID of record to display
248 :return: action definition dict
250 return {'type': 'ir.actions.act_window',
251 'view_mode': 'form,tree',
253 'res_model': self._name,
256 def edi_metadata(self, cr, uid, records, context=None):
257 """Return a list containing the boilerplate EDI structures for
258 exporting ``records`` as EDI, including
261 The metadata fields always include::
264 '__model': 'some.model', # record model
265 '__module': 'module', # require module
266 '__id': 'module:db-uuid:model.id', # unique global external ID for the record
267 '__last_update': '2011-01-01 10:00:00', # last update date in UTC!
268 '__version': 1, # EDI spec version
269 '__generator' : 'OpenERP', # EDI generator
270 '__generator_version' : [6,1,0], # server version, to check compatibility.
274 :param list(browse_record) records: records to export
275 :return: list of dicts containing boilerplate EDI metadata for each record,
276 at the corresponding index from ``records``.
278 ir_attachment = self.pool.get('ir.attachment')
280 for record in records:
281 ext_id = self._edi_external_id(cr, uid, record, context=context)
284 '__last_update': last_update_for(record),
285 '__model' : record._name,
286 '__module' : record._original_module,
287 '__version': EDI_PROTOCOL_VERSION,
288 '__generator': EDI_GENERATOR,
289 '__generator_version': EDI_GENERATOR_VERSION,
291 attachment_ids = ir_attachment.search(cr, uid, [('res_model','=', record._name), ('res_id', '=', record.id)])
294 for attachment in ir_attachment.browse(cr, uid, attachment_ids, context=context):
296 'name' : attachment.name,
297 'content': attachment.datas, # already base64 encoded!
298 'file_name': attachment.datas_fname,
300 edi_dict.update(__attachments=attachments)
301 results.append(edi_dict)
304 def edi_m2o(self, cr, uid, record, context=None):
305 """Return a m2o EDI representation for the given record.
307 The EDI format for a many2one is::
309 ['unique_external_id', 'Document Name']
311 edi_ext_id = self._edi_external_id(cr, uid, record, context=context)
312 relation_model = record._model
313 name = relation_model.name_get(cr, uid, [record.id], context=context)
314 name = name and name[0][1] or False
315 return [edi_ext_id, name]
317 def edi_o2m(self, cr, uid, records, edi_struct=None, context=None):
318 """Return a list representing a O2M EDI relationship containing
319 all the given records, according to the given ``edi_struct``.
320 This is basically the same as exporting all the record using
321 :meth:`~.edi_export` with the given ``edi_struct``, and wrapping
322 the results in a list.
326 [ # O2M fields would be a list of dicts, with their
327 { '__id': 'module:db-uuid.id', # own __id.
328 '__last_update': 'iso date', # update date
336 for record in records:
337 result += record._model.edi_export(cr, uid, [record], edi_struct=edi_struct, context=context)
340 def edi_m2m(self, cr, uid, records, context=None):
341 """Return a list representing a M2M EDI relationship directed towards
342 all the given records.
343 This is basically the same as exporting all the record using
344 :meth:`~.edi_m2o` and wrapping the results in a list.
348 # M2M fields are exported as a list of pairs, like a list of M2O values
350 ['module:db-uuid.id1', 'Task 01: bla bla'],
351 ['module:db-uuid.id2', 'Task 02: bla bla']
354 return [self.edi_m2o(cr, uid, r, context=context) for r in records]
356 def edi_export(self, cr, uid, records, edi_struct=None, context=None):
357 """Returns a list of dicts representing EDI documents containing the
358 records, and matching the given ``edi_struct``, if provided.
360 :param edi_struct: if provided, edi_struct should be a dictionary
361 with a skeleton of the fields to export.
362 Basic fields can have any key as value, but o2m
363 values should have a sample skeleton dict as value,
364 to act like a recursive export.
365 For example, for a res.partner record::
376 Any field not specified in the edi_struct will not
377 be included in the exported data. Fields with no
378 value (False) will be omitted in the EDI struct.
379 If edi_struct is omitted, no fields will be exported
381 if edi_struct is None:
383 fields_to_export = edi_struct.keys()
385 for record in records:
386 edi_dict = self.edi_metadata(cr, uid, [record], context=context)[0]
387 for field in fields_to_export:
388 column = self._all_columns[field].column
389 value = getattr(record, field)
390 if not value and value not in ('', 0):
392 elif column._type == 'many2one':
393 value = self.edi_m2o(cr, uid, value, context=context)
394 elif column._type == 'many2many':
395 value = self.edi_m2m(cr, uid, value, context=context)
396 elif column._type == 'one2many':
397 value = self.edi_o2m(cr, uid, value, edi_struct=edi_struct.get(field, {}), context=context)
398 edi_dict[field] = value
399 results.append(edi_dict)
402 def _edi_get_object_by_name(self, cr, uid, name, model_name, context=None):
403 model = self.pool[model_name]
404 search_results = model.name_search(cr, uid, name, operator='=', context=context)
405 if len(search_results) == 1:
406 return model.browse(cr, uid, search_results[0][0], context=context)
409 def _edi_generate_report_attachment(self, cr, uid, record, context=None):
410 """Utility method to generate the first PDF-type report declared for the
411 current model with ``usage`` attribute set to ``default``.
412 This must be called explicitly by models that need it, usually
413 at the beginning of ``edi_export``, before the call to ``super()``."""
414 ir_actions_report = self.pool.get('ir.actions.report.xml')
415 matching_reports = ir_actions_report.search(cr, uid, [('model','=',self._name),
416 ('report_type','=','pdf'),
417 ('usage','=','default')])
419 report = ir_actions_report.browse(cr, uid, matching_reports[0])
420 result, format = openerp.report.render_report(cr, uid, [record.id], report.report_name, {'model': self._name}, context=context)
421 eval_context = {'time': time, 'object': record}
422 if not report.attachment or not eval(report.attachment, eval_context):
423 # no auto-saving of report as attachment, need to do it manually
424 result = base64.b64encode(result)
425 file_name = record.name_get()[0][1]
426 file_name = re.sub(r'[^a-zA-Z0-9_-]', '_', file_name)
428 self.pool.get('ir.attachment').create(cr, uid,
432 'datas_fname': file_name,
433 'res_model': self._name,
439 def _edi_import_attachments(self, cr, uid, record_id, edi, context=None):
440 ir_attachment = self.pool.get('ir.attachment')
441 for attachment in edi.get('__attachments', []):
442 # check attachment data is non-empty and valid
445 file_data = base64.b64decode(attachment.get('content'))
448 assert file_data, 'Incorrect/Missing attachment file content.'
449 assert attachment.get('name'), 'Incorrect/Missing attachment name.'
450 assert attachment.get('file_name'), 'Incorrect/Missing attachment file name.'
451 assert attachment.get('file_name'), 'Incorrect/Missing attachment file name.'
452 ir_attachment.create(cr, uid, {'name': attachment['name'],
453 'datas_fname': attachment['file_name'],
454 'res_model': self._name,
456 # should be pure 7bit ASCII
457 'datas': str(attachment['content']),
461 def _edi_get_object_by_external_id(self, cr, uid, external_id, model, context=None):
462 """Returns browse_record representing object identified by the model and external_id,
463 or None if no record was found with this external id.
465 :param external_id: fully qualified external id, in the EDI form
466 ``module:db_uuid:identifier``.
467 :param model: model name the record belongs to.
469 ir_model_data = self.pool.get('ir.model.data')
470 # external_id is expected to have the form: ``module:db_uuid:model.random_name``
471 ext_id_members = split_external_id(external_id)
472 db_uuid = self.pool.get('ir.config_parameter').get_param(cr, uid, 'database.uuid')
473 module = ext_id_members['module']
474 ext_id = ext_id_members['id']
476 ext_db_uuid = ext_id_members['db_uuid']
478 modules.append('%s:%s' % (module, ext_id_members['db_uuid']))
479 if ext_db_uuid is None or ext_db_uuid == db_uuid:
480 # local records may also be registered without the db_uuid
481 modules.append(module)
482 data_ids = ir_model_data.search(cr, uid, [('model','=',model),
484 ('module','in',modules)])
486 model = self.pool[model]
487 data = ir_model_data.browse(cr, uid, data_ids[0], context=context)
488 if model.exists(cr, uid, [data.res_id]):
489 return model.browse(cr, uid, data.res_id, context=context)
490 # stale external-id, cleanup to allow re-import, as the corresponding record is gone
491 ir_model_data.unlink(cr, 1, [data_ids[0]])
493 def edi_import_relation(self, cr, uid, model, value, external_id, context=None):
494 """Imports a M2O/M2M relation EDI specification ``[external_id,value]`` for the
495 given model, returning the corresponding database ID:
497 * First, checks if the ``external_id`` is already known, in which case the corresponding
498 database ID is directly returned, without doing anything else;
499 * If the ``external_id`` is unknown, attempts to locate an existing record
500 with the same ``value`` via name_search(). If found, the given external_id will
501 be assigned to this local record (in addition to any existing one)
502 * If previous steps gave no result, create a new record with the given
503 value in the target model, assign it the given external_id, and return
506 :param str value: display name of the record to import
507 :param str external_id: fully-qualified external ID of the record
508 :return: database id of newly-imported or pre-existing record
510 _logger.debug("%s: Importing EDI relationship [%r,%r]", model, external_id, value)
511 target = self._edi_get_object_by_external_id(cr, uid, external_id, model, context=context)
512 need_new_ext_id = False
514 _logger.debug("%s: Importing EDI relationship [%r,%r] - ID not found, trying name_get.",
515 self._name, external_id, value)
516 target = self._edi_get_object_by_name(cr, uid, value, model, context=context)
517 need_new_ext_id = True
519 _logger.debug("%s: Importing EDI relationship [%r,%r] - name not found, creating it.",
520 self._name, external_id, value)
521 # also need_new_ext_id here, but already been set above
522 model = self.pool[model]
523 res_id, _ = model.name_create(cr, uid, value, context=context)
524 target = model.browse(cr, uid, res_id, context=context)
526 _logger.debug("%s: Importing EDI relationship [%r,%r] - record already exists with ID %s, using it",
527 self._name, external_id, value, target.id)
529 ext_id_members = split_external_id(external_id)
530 # module name is never used bare when creating ir.model.data entries, in order
531 # to avoid being taken as part of the module's data, and cleanup up at next update
532 module = "%s:%s" % (ext_id_members['module'], ext_id_members['db_uuid'])
533 # create a new ir.model.data entry for this value
534 self._edi_external_id(cr, uid, target, existing_id=ext_id_members['id'], existing_module=module, context=context)
537 def edi_import(self, cr, uid, edi, context=None):
538 """Imports a dict representing an EDI document into the system.
540 :param dict edi: EDI document to import
541 :return: the database ID of the imported record
543 assert self._name == edi.get('__import_model') or \
544 ('__import_model' not in edi and self._name == edi.get('__model')), \
545 "EDI Document Model and current model do not match: '%s' (EDI) vs '%s' (current)." % \
546 (edi.get('__model'), self._name)
548 # First check the record is now already known in the database, in which case it is ignored
549 ext_id_members = split_external_id(edi['__id'])
550 existing = self._edi_get_object_by_external_id(cr, uid, ext_id_members['full'], self._name, context=context)
552 _logger.info("'%s' EDI Document with ID '%s' is already known, skipping import!", self._name, ext_id_members['full'])
556 o2m_todo = {} # o2m values are processed after their parent already exists
557 for field_name, field_value in edi.iteritems():
558 # skip metadata and empty fields
559 if field_name.startswith('__') or field_value is None or field_value is False:
561 field_info = self._all_columns.get(field_name)
563 _logger.warning('Ignoring unknown field `%s` when importing `%s` EDI document.', field_name, self._name)
565 field = field_info.column
566 # skip function/related fields
567 if isinstance(field, fields.function):
568 _logger.warning("Unexpected function field value is found in '%s' EDI document: '%s'." % (self._name, field_name))
570 relation_model = field._obj
571 if field._type == 'many2one':
572 record_values[field_name] = self.edi_import_relation(cr, uid, relation_model,
573 field_value[1], field_value[0],
575 elif field._type == 'many2many':
576 record_values[field_name] = [self.edi_import_relation(cr, uid, relation_model, m2m_value[1],
577 m2m_value[0], context=context)
578 for m2m_value in field_value]
579 elif field._type == 'one2many':
580 # must wait until parent report is imported, as the parent relationship
581 # is often required in o2m child records
582 o2m_todo[field_name] = field_value
584 record_values[field_name] = field_value
586 module_ref = "%s:%s" % (ext_id_members['module'], ext_id_members['db_uuid'])
587 record_id = self.pool.get('ir.model.data')._update(cr, uid, self._name, module_ref, record_values,
588 xml_id=ext_id_members['id'], context=context)
590 record_display, = self.name_get(cr, uid, [record_id], context=context)
592 # process o2m values, connecting them to their parent on-the-fly
593 for o2m_field, o2m_value in o2m_todo.iteritems():
594 field = self._all_columns[o2m_field].column
595 dest_model = self.pool[field._obj]
596 for o2m_line in o2m_value:
597 # link to parent record: expects an (ext_id, name) pair
598 o2m_line[field._fields_id] = (ext_id_members['full'], record_display[1])
599 dest_model.edi_import(cr, uid, o2m_line, context=context)
601 # process the attachments, if any
602 self._edi_import_attachments(cr, uid, record_id, edi, context=context)
606 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: