From d59572ccc2bf4d8abfef2e2cac500bad033020ea Mon Sep 17 00:00:00 2001 From: Olivier Dony Date: Tue, 4 Oct 2011 18:43:39 +0200 Subject: [PATCH] [ADD] edi: moved from server into separate module bzr revid: odo@openerp.com-20111004164339-4fxlpxjadsm499kb --- addons/edi/__init__.py | 23 + addons/edi/__openerp__.py | 48 ++ addons/edi/edi_service.py | 70 +++ addons/edi/models/__init__.py | 24 + addons/edi/models/edi.py | 589 +++++++++++++++++++++++++ addons/edi/models/res_company.py | 121 +++++ addons/edi/models/res_partner.py | 64 +++ addons/edi/test/test_edi_documents.yml | 43 ++ 8 files changed, 982 insertions(+) create mode 100644 addons/edi/__init__.py create mode 100644 addons/edi/__openerp__.py create mode 100644 addons/edi/edi_service.py create mode 100644 addons/edi/models/__init__.py create mode 100644 addons/edi/models/edi.py create mode 100644 addons/edi/models/res_company.py create mode 100644 addons/edi/models/res_partner.py create mode 100644 addons/edi/test/test_edi_documents.yml diff --git a/addons/edi/__init__.py b/addons/edi/__init__.py new file mode 100644 index 00000000000..4e266ec8443 --- /dev/null +++ b/addons/edi/__init__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Business Applications +# Copyright (c) 2011 OpenERP S.A. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +import models +import edi_service \ No newline at end of file diff --git a/addons/edi/__openerp__.py b/addons/edi/__openerp__.py new file mode 100644 index 00000000000..d262a5e0434 --- /dev/null +++ b/addons/edi/__openerp__.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Business Applications +# Copyright (c) 2011 OpenERP S.A. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## +{ + 'name': 'Electronic Data Interchange (EDI)', + 'version': '1.0', + 'category': 'Tools', + 'complexity': "easy", + 'description': """ +Provides a common EDI platform that other Applications can use +============================================================== + +OpenERP specifies a generic EDI format for exchanging business +documents between different systems, and provides generic +mechanisms to import and export them. + +More details about OpenERP's EDI format may be found in the +technical OpenERP documentation at http://doc.openerp.com + """, + 'author': 'OpenERP SA', + 'website': 'http://www.openerp.com', + 'depends': ['base'], + 'test': [ + 'test/partner_edi_test.yml', + ], + 'installable': True, + 'active': False, + #'certificate': TODO +} + +# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: \ No newline at end of file diff --git a/addons/edi/edi_service.py b/addons/edi/edi_service.py new file mode 100644 index 00000000000..35b6d32e272 --- /dev/null +++ b/addons/edi/edi_service.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Business Applications +# Copyright (c) 2011 OpenERP S.A. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## +import logging + +import netsvc +import openerp + +_logger = logging.getLogger('edi.service') + +class edi(netsvc.ExportService): + + def __init__(self, name="edi"): + netsvc.ExportService.__init__(self, name) + + def _edi_dispatch(self, db_name, method_name, *method_args): + registry = openerp.modules.registry.RegistryManager.get(db_name) + assert registry, 'Unknown database %s' % db_name + edi_document = registry['edi.document'] + cr = registry.db.cursor() + try: + res = None + res = getattr(edi_document,method_name)(cr, *method_args) + cr.commit() + except Exception: + _logger.exception('Failed to execute EDI method %s with args %r', method_name, method_args) + cr.rollback() + finally: + cr.close() + return res + + def exp_get_edi_document(self, db_name, edi_token): + return self._edi_dispatch(db_name, 'get_document', 1, edi_token) + + def exp_import_edi_document(self, db, uid, passwd, edi_document, context=None): + return self._edi_dispatch(db_name, 'import_edi', 1, edi_document, None) + + def exp_import_edi_url(self, db, uid, passwd, edi_url, context=None): + return self._edi_dispatch(db_name, 'import_edi', 1, None, edi_url) + + def dispatch(self, method, params): + if method in ['import_edi_document', 'import_edi_url']: + (db, uid, passwd ) = params[0:3] + openerp.service.security.check(db, uid, passwd) + elif method in ['get_edi_document']: + # No security check for these methods + pass + else: + raise KeyError("Method not found: %s" % method) + fn = getattr(self, 'exp_'+method) + return fn(*params) + +edi() \ No newline at end of file diff --git a/addons/edi/models/__init__.py b/addons/edi/models/__init__.py new file mode 100644 index 00000000000..201f1c6f073 --- /dev/null +++ b/addons/edi/models/__init__.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Business Applications +# Copyright (c) 2011 OpenERP S.A. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +import edi +import res_partner +import res_company \ No newline at end of file diff --git a/addons/edi/models/edi.py b/addons/edi/models/edi.py new file mode 100644 index 00000000000..734fa61df8f --- /dev/null +++ b/addons/edi/models/edi.py @@ -0,0 +1,589 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Business Applications +# Copyright (c) 2011 OpenERP S.A. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +import base64 +import hashlib +import json +import logging +import re +import time +import urllib2 + +import openerp +import openerp.release as release +from osv import osv,fields +from tools.translate import _ +from tools.parse_version import parse_version + +EXTERNAL_ID_PATTERN = re.compile(r'^(\S+:)?(\S+?)\.(\S+)$') + +def split_external_id(ext_id): + match = EXTERNAL_ID_PATTERN.match(ext_id) + assert match, \ + _("'%s' is an invalid external ID") % (ext_id) + return {'module': match.group(1) and match.group(1)[:-1], + 'db_uuid': match.group(2), + 'id': match.group(3), + 'full': match.group(0)} + +def safe_unique_id(database_id, model, record_id): + """Generate a unique string to represent a (database_uuid,model,record_id) pair + without being too long, and with a very low probability of collisions. + """ + msg = "%s-%s-%s-%s" % (time.time(), database_id, model, record_id) + digest = hashlib.sha1(msg).digest() + # fold the sha1 20 bytes digest to 9 bytes + digest = ''.join(chr(ord(x) ^ ord(y)) for (x,y) in zip(digest[:9], digest[9:-2])) + # b64-encode the 9-bytes folded digest to a reasonable 12 chars ASCII ID + digest = base64.urlsafe_b64encode(digest) + return '%s-%s' % (model.replace('.','_'), digest) + +def version_tuple(): + return parse_version(release.version) + +def last_update_for(record): + """Returns the last update timestamp for the given record, + if available, otherwise False + """ + if record._model._log_access: + record_log = record.perm_read()[0] + return record_log.get('write_date') or record_log.get('create_date') or False + return False + +_logger = logging.getLogger('edi') + +class edi_document(osv.osv): + _name = 'edi.document' + _description = 'EDI Document' + _columns = { + 'name': fields.char("EDI token", size = 128, help="Unique identifier for retrieving an EDI document."), + 'document': fields.text("Document", help="EDI document content") + } + _sql_constraints = [ + ('name_uniq', 'unique (name)', 'EDI Tokens must be unique!') + ] + + def new_edi_token(self, cr, uid, record): + """Return a new, random unique token to identify this model record, + and to be used as token when exporting it as an EDI document. + + :param browse_record record: model record for which a token is needed + """ + db_uuid = self.pool.get('ir.config_parameter').get_param(cr, uid, 'database.uuid') + edi_token = hashlib.sha256('%s-%s-%s-%s' % (time.time(), db_uuid, record._name, record.id)).hexdigest() + return edi_token + + def serialize(self, edi_documents): + """Serialize the given EDI document structures (Python dicts holding EDI data), + using JSON serialization. + + :param [dict] edi_documents: list of EDI document structures to serialize + :return: UTF-8 encoded string containing the serialized document + """ + serialized_list = json.dumps(edi_documents) + return serialized_list + + def generate_edi(self, cr, uid, records, context=None): + """Generates a final EDI document containing the EDI serialization + of the given records, which should all be instances of a Model + that has the :meth:`~.edi` mixin. The document is not saved in the + database, this is done by :meth:`~.export_edi`. + + :param list(browse_record) records: records to export as EDI + :return: UTF-8 encoded string containing the serialized records + """ + edi_list = [] + for record in records: + record_model_obj = self.pool.get(record._name) + edi_list += record_model_obj.edi_export(cr, uid, [record], context=context) + return self.serialize(edi_list) + + def get_document(self, cr, uid, edi_token, context=None): + """Retrieve the EDI document corresponding to the given edi_token. + + :return: EDI document string + :raise: ValueError if requested EDI token does not match any know document + """ + _logger.debug("get_document(%s)", edi_token) + edi_ids = self.search(cr, uid, [('name','=', edi_token)], context=context) + if not edi_ids: + raise ValueError('Invalid EDI token: %s' % edi_token) + edi = self.browse(cr, uid, edi_ids[0], context=context) + return edi.document + + def load_edi(self, cr, uid, edi_documents, context=None): + """Import the given EDI document structures into the system, using + :meth:`~.import_edi`. + + :param edi_documents: list of Python dicts containing the deserialized + version of EDI documents + :return: list of (model, id) pairs containing the model and database ID + of all records that were imported in the system + """ + ir_module = self.pool.get('ir.module.module') + res = [] + for edi_document in edi_documents: + module = edi_document.get('__module') + if module != 'base' and not ir_module.search(cr, uid, [('name','=',module),('state','=','installed')]): + raise osv.except_osv(_('Missing Application'), + _("The document you are trying to import requires the OpenERP `%s` application. " + "The OpenERP configuration assistant will help with this if you are connected as an administrator.")%(module,)) + model = edi_document.get('__model') + assert model, '__model attribute is required in each EDI document' + model_obj = self.pool.get(model) + record_id = model_obj.edi_import(cr, uid, edi_document, context=context) + res.append((model, record_id)) + return res + + def deserialize(self, edi_documents_string): + """Return deserialized version of the given EDI Document string. + + :param str|unicode edi_documents_string: UTF-8 string (or unicode) containing + JSON-serialized EDI document(s) + :return: Python object representing the EDI document(s) (usually a list of dicts) + """ + return json.loads(edi_documents_string) + + def export_edi(self, cr, uid, records, context=None): + """Export the given database records as EDI documents, stores them + permanently with a new unique EDI token, for later retrieval via :meth:`~.get_document`, + and returns the list of the new corresponding ``ir.edi.document`` records. + + :param records: list of browse_record of any model + :return: list of IDs of the new ``ir.edi.document`` entries, in the same + order as the provided ``records``. + """ + exported_ids = [] + for record in records: + document = self.generate_edi(cr, uid, [record], context) + token = self.new_edi_token(cr, uid, record) + self.create(cr, uid, { + 'name': token, + 'document': document + }, context=context) + exported_ids.append(token) + return exported_ids + + def import_edi(self, cr, uid, edi_document=None, edi_url=None, context=None): + """Import a JSON serialized EDI Document string into the system, first retrieving it + from the given ``edi_url`` if provided. + + :param str|unicode edi_document: UTF-8 string or unicode containing JSON-serialized + EDI Document to import. Must not be provided if + ``edi_url`` is given. + :param str|unicode edi_url: URL where the EDI document (same format as ``edi_document``) + may be retrieved, without authentication. + """ + if edi_url: + assert not edi_document, 'edi_document must not be provided if edi_url is given' + edi_document = urllib2.urlopen(edi_url).read() + assert edi_document, 'EDI Document is empty!' + edi_documents = self.deserialize(edi_document) + return self.load_edi(cr, uid, edi_documents, context=context) + + +class EDIMixin(object): + """Mixin class for Model objects that want be exposed as EDI documents. + Classes that inherit from this mixin class should override the + ``edi_import()`` and ``edi_export()`` methods to implement their + specific behavior, based on the primitives provided by this mixin.""" + + # private method, not RPC-exposed as it creates ir.model.data entries as + # SUPERUSER based on its parameters + def _edi_external_id(self, cr, uid, record, existing_id=None, existing_module=None, + context=None): + """Generate/Retrieve unique external ID for ``record``. + Each EDI record and each relationship attribute in it is identified by a + unique external ID, which includes the database's UUID, as a way to + refer to any record within any OpenERP instance, without conflict. + + For OpenERP records that have an existing "External ID" (i.e. an entry in + ir.model.data), the EDI unique identifier for this record will be made of + "%s:%s:%s" % (module, database UUID, ir.model.data ID). The database's + UUID MUST NOT contain a colon characters (this is guaranteed by the + UUID algorithm). + + For records that have no existing ir.model.data entry, a new one will be + created during the EDI export. It is recommended that the generated external ID + contains a readable reference to the record model, plus a unique value that + hides the database ID. If ``existing_id`` is provided (because it came from + an import), it will be used instead of generating a new one. + If ``existing_module`` is provided (because it came from + an import), it will be used instead of using local values. + + :param browse_record record: any browse_record needing an EDI external ID + :param string existing_id: optional existing external ID value, usually coming + from a just-imported EDI record, to be used instead + of generating a new one + :param string existing_module: optional existing module name, usually in the + format ``module:db_uuid`` and coming from a + just-imported EDI record, to be used instead + of local values + :return: the full unique External ID to use for record + """ + ir_model_data = self.pool.get('ir.model.data') + db_uuid = self.pool.get('ir.config_parameter').get_param(cr, uid, 'database.uuid') + ext_id = self.get_external_id(cr, uid, [record.id])[record.id] + if not ext_id: + ext_id = existing_id or safe_unique_id(db_uuid, record._name, record.id) + # ID is unique cross-db thanks to db_uuid (already included in existing_module) + module = existing_module or "%s:%s" % (record._module, db_uuid) + _logger.debug("%s: Generating new external ID `%s.%s` for %r", self._name, + module, ext_id, record) + ir_model_data.create(cr, openerp.SUPERUSER_ID, + {'name': ext_id, + 'model': record._name, + 'module': module, + 'res_id': record.id}) + else: + module, ext_id = ext_id.split('.') + if not ':' in module: + # this record was not previously EDI-imported + assert module == record._module, 'Module mismatch between record and its current'\ + 'external ID' + # ID is unique cross-db thanks to db_uuid + module = "%s:%s" % (module, db_uuid) + + return '%s.%s' % (module, ext_id) + + def edi_metadata(self, cr, uid, records, context=None): + """Return a list containing the boilerplate EDI structures for + exporting ``records`` as EDI, including + the metadata fields + + The metadata fields always include:: + + { + '__model': 'some.model', # record model + '__module': 'module', # require module + '__id': 'module:db-uuid:model.id', # unique global external ID for the record + '__last_update': '2011-01-01 10:00:00', # last update date in UTC! + '__version' : [6,1,0], # server version, to check compatibility. + '__attachments_': + } + + :param list(browse_record) records: records to export + :return: list of dicts containing boilerplate EDI metadata for each record, + at the corresponding index from ``records``. + """ + data_ids = [] + ir_attachment = self.pool.get('ir.attachment') + results = [] + version = version_tuple() + for record in records: + attachment_ids = ir_attachment.search(cr, uid, [('res_model','=', record._name), ('res_id', '=', record.id)]) + attachments = [] + for attachment in ir_attachment.browse(cr, uid, attachment_ids, context=context): + attachments.append({ + 'name' : attachment.name, + 'content': attachment.datas, # already base64 encoded! + 'file_name': attachment.datas_fname, + }) + ext_id = self._edi_external_id(cr, uid, record, context=context) + edi_dict = { + '__id': ext_id, + '__last_update': last_update_for(record), + '__model' : record._name, + '__module' : record._module, + '__version': version, + '__attachments': attachments, + } + results.append(edi_dict) + return results + + def edi_m2o(self, cr, uid, record, context=None): + """Return a m2o EDI representation for the given record. + + The EDI format for a many2one is:: + + ['unique_external_id', 'Document Name'] + """ + edi_ext_id = self._edi_external_id(cr, uid, record, context=context) + relation_model = record._model + name = relation_model.name_get(cr, uid, [record.id], context=context) + name = name and name[0][1] or False + return [edi_ext_id, name] + + def edi_o2m(self, cr, uid, records, edi_struct=None, context=None): + """Return a list representing a O2M EDI relationship containing + all the given records, according to the given ``edi_struct``. + This is basically the same as exporting all the record using + :meth:`~.edi_export` with the given ``edi_struct``, and wrapping + the results in a list. + + Example:: + + [ # O2M fields would be a list of dicts, with their + { '__id': 'module:db-uuid.id', # own __id. + '__last_update': 'iso date', # update date + 'name': 'some name', + #... + }, + # ... + ], + """ + result = [] + for record in records: + result += record._model.edi_export(cr, uid, [record], edi_struct=edi_struct, context=context) + return result + + def edi_m2m(self, cr, uid, records, context=None): + """Return a list representing a M2M EDI relationship directed towards + all the given records. + This is basically the same as exporting all the record using + :meth:`~.edi_m2o` and wrapping the results in a list. + + Example:: + + # M2M fields are exported as a list of pairs, like a list of M2O values + [ + ['module:db-uuid.id1', 'Task 01: bla bla'], + ['module:db-uuid.id2', 'Task 02: bla bla'] + ] + """ + return [self.edi_m2o(cr, uid, r, context=context) for r in records] + + def edi_export(self, cr, uid, records, edi_struct=None, context=None): + """Returns a list of dicts representing an edi.document containing the + records, and matching the given ``edi_struct``, if provided. + + :param edi_struct: if provided, edi_struct should be a dictionary + with a skeleton of the fields to export. + Basic fields can have any key as value, but o2m + values should have a sample skeleton dict as value, + to act like a recursive export. + For example, for a res.partner record:: + + edi_struct: { + 'name': True, + 'company_id': True, + 'address': { + 'name': True, + 'street': True, + } + } + + Any field not specified in the edi_struct will not + be included in the exported data. Fields with no + value (False) will be omitted in the EDI struct. + If edi_struct is omitted, no fields will be exported + """ + if edi_struct is None: + edi_struct = {} + fields_to_export = edi_struct.keys() + results = [] + for record in records: + edi_dict = self.edi_metadata(cr, uid, [record], context=context)[0] + for field in fields_to_export: + column = self._all_columns[field].column + value = getattr(record, field) + if not value: + continue + #if _fields[field].has_key('function') or _fields[field].has_key('related_columns'): + # # Do not Export Function Fields and related fields + # continue + elif column._type == 'many2one': + value = self.edi_m2o(cr, uid, value, context=context) + elif column._type == 'many2many': + value = self.edi_m2m(cr, uid, value, context=context) + elif column._type == 'one2many': + value = self.edi_o2m(cr, uid, value, edi_struct=edi_struct.get(field, {}), context=context) + edi_dict[field] = value + results.append(edi_dict) + return results + + def _edi_get_object_by_name(self, cr, uid, name, model_name, context=None): + model = self.pool.get(model_name) + search_results = model.name_search(cr, uid, name, operator='=', context=context) + if len(search_results) == 1: + return model.browse(cr, uid, search_results[0][0], context=context) + return False + + def _edi_import_attachments(self, cr, uid, record_id, edi_document, context=None): + ir_attachment = self.pool.get('ir.attachment') + for attachment in edi_document.get('__attachments', []): + # check attachment data is non-empty and valid + file_data = None + try: + file_data = base64.b64decode(attachment.get('content')) + except TypeError: + pass + assert file_data, 'Incorrect/Missing attachment file content' + assert attachment.get('name'), 'Incorrect/Missing attachment name' + assert attachment.get('file_name'), 'Incorrect/Missing attachment file name' + assert attachment.get('file_name'), 'Incorrect/Missing attachment file name' + ir_attachment.create(cr, uid, {'name': attachment['name'], + 'datas_fname': attachment['file_name'], + 'res_model': self._name, + 'res_id': record_id, + # should be pure 7bit ASCII + 'datas': str(attachment['content']), + }, context=context) + + + def _edi_get_object_by_external_id(self, cr, uid, external_id, model, context=None): + """Returns browse_record representing object identified by the model and external_id, + or None if no record was found with this external id. + + :param external_id: fully qualified external id, in the EDI form + ``module:db_uuid:identifier``. + :param model: model name the record belongs to. + """ + ir_model_data = self.pool.get('ir.model.data') + # external_id is expected to have the form: ``module:db_uuid:model.random_name`` + ext_id_members = split_external_id(external_id) + db_uuid = self.pool.get('ir.config_parameter').get_param(cr, uid, 'database.uuid') + module = ext_id_members['module'] + ext_id = ext_id_members['id'] + ext_module = '%s:%s' % (module, ext_id_members['db_uuid']) + data_ids = ir_model_data.search(cr, uid, [('model','=',model), + ('name','=',ext_id), + ('module','in',[ext_module,module])]) + if data_ids: + model = self.pool.get(model) + data = ir_model_data.browse(cr, uid, data_ids[0], context=context) + result = model.browse(cr, uid, data.res_id, context=context) + return result + + def edi_import_relation(self, cr, uid, model, value, external_id, context=None): + """Imports a M2O/M2M relation EDI specification ``[external_id,value]`` for the + given model, returning the corresponding database ID: + + * First, checks if the ``external_id`` is already known, in which case the corresponding + database ID is directly returned, without doing anything else; + * If the ``external_id`` is unknown, attempts to locate an existing record + with the same ``value`` via name_search(). If found, the given external_id will + be assigned to this local record (in addition to any existing one) + * If previous steps gave no result, create a new record with the given + value in the target model, assign it the given external_id, and return + the new database ID + """ + _logger.debug("%s: Importing EDI relationship [%r,%r]", self._name, external_id, value) + target = self._edi_get_object_by_external_id(cr, uid, external_id, model, context=context) + need_new_ext_id = False + if not target: + _logger.debug("%s: Importing EDI relationship [%r,%r] - ID not found, trying name_get", + self._name, external_id, value) + target = self._edi_get_object_by_name(cr, uid, value, model, context=context) + need_new_ext_id = True + if not target: + _logger.debug("%s: Importing EDI relationship [%r,%r] - name not found, creating it!", + self._name, external_id, value) + # also need_new_ext_id here, but already been set above + res_id, name = self.name_create(cr, uid, value, context=context) + target = model.browse(cr, uid, res_id, context=context) + if need_new_ext_id: + ext_id_members = split_external_id(external_id) + # module name is never used bare when creating ir.model.data entries, in order + # to avoid being taken as part of the module's data, and cleanup up at next update + module = "%s:%s" % (ext_id_members['module'], ext_id_members['db_uuid']) + # create a new ir.model.data entry for this value + self._edi_external_id(cr, uid, target, existing_id=ext_id_members['id'], existing_module=module, context=context) + return target.id + + def edi_import(self, cr, uid, edi_document, context=None): + """Imports a dict representing an edi.document into the system, + applying the following rules. + + All relationship fields are exported in a special way, and provide their own + unique identifier, so that we can avoid duplication of records when importing. + + #. Many2One + See :meth:`~.edi_import_relation` + + #. One2Many + O2M fields are always exported as a list of dicts, where each dict corresponds + to a full EDI record. The import should not update existing records + if they already exist, it should only link them to the parent object, in this + fashion: + * First import the parent object, using the usual procedire + * Look for a record that matches the db_id provided in the __id field. If + found, keep the corresponding database id, and connect it to the parent. + * If not found via db_id, create a new entry using the same method that + imports a full EDI record, grab the resulting db id, and use it to + connect to the parent. + + #: Many2Many + M2M fields are always exported as a list of pairs similar to M2O. + For each pair in the M2M: + * Perform the same steps as for a Many2One (See :meth:`~.edi_import_relation`) + * After finding the database ID of the final record in the database, + connect it to the parent record. + """ + assert self._name == edi_document['__model'],\ + "EDI Document Model and current model do not match: '%s' (EDI) vs '%s' (current)" % \ + (edi_document['__model'], self._name) + + # First check the record is now already known in the database, in which case it is ignored + ext_id_members = split_external_id(edi_document['__id']) + existing_id = self._edi_get_object_by_external_id(cr, uid, ext_id_members['full'], self._name, context=context) + if existing_id: + _logger.info("'%s' EDI Document with ID '%s' is already known, skipping import!", self._name, ext_id_members['full']) + return + + record_values = {} + o2m_todo = {} # o2m values are processed after their parent already exists + for field_name, field_value in edi_document.iteritems(): + # skip metadata and empty fields + if field_name.startswith('__') or field_value is None or field_value is False: + continue + field = self._all_columns[field_name].column + # skip function/related fields + if isinstance(field, fields.function): + _logger.warning("Unexpected function field value found in '%s' EDI document: '%s'" % (self._name, field_name)) + continue + relation_model = field._obj + if field._type == 'many2one': + record_values[field_name] = self.edi_import_relation(cr, uid, relation_model, + field_value[1], field_value[0], + context=context) + elif field._type == 'many2many': + record_values[field_name] = [self.edi_import_relation(cr, uid, relation_model, m2m_value[1], + m2m_value[0], context=context) + for m2m_value in field_value] + elif field._type == 'one2many': + # must wait until parent report is imported, as the parent relationship + # is often required in o2m child records + o2m_todo[field_name] = field_value + else: + record_values[field_name] = field_value + + module_ref = "%s:%s" % (ext_id_members['module'], ext_id_members['db_uuid']) + record_id = self.pool.get('ir.model.data')._update(cr, uid, self._name, module_ref, record_values, + xml_id=ext_id_members['id'], context=context) + + record_display, = self.name_get(cr, uid, [record_id], context=context) + + # process o2m values, connecting them to their parent on-the-fly + for o2m_field, o2m_value in o2m_todo.iteritems(): + field = self._all_columns[o2m_field].column + dest_model = self.pool.get(field._obj) + for o2m_line in o2m_value: + # link to parent record: expects an (ext_id, name) pair + o2m_line[field._fields_id] = (ext_id_members['full'], record_display[1]) + dest_model.edi_import(cr, uid, o2m_line, context=context) + + # process the attachments, if any + self._edi_import_attachments(cr, uid, record_id, edi_document, context=context) + + return record_id + +# vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: \ No newline at end of file diff --git a/addons/edi/models/res_company.py b/addons/edi/models/res_company.py new file mode 100644 index 00000000000..0641ff0e474 --- /dev/null +++ b/addons/edi/models/res_company.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Management Solution +# Copyright (C) 2004-2011 Tiny SPRL (). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +#from osv import fields,osv + +# TODO: CHECK below, seems useless +# +#class res_company(osv.osv): +# """Helper subclass for res.company providing util methods for working with +# companies in the context of EDI import/export. The res.company object +# itself is not EDI-exportable""" +# _inherit = "res.company" +# +# def edi_import_as_partner(self, cr, uid, edi_document, values=None, context=None): +# """ +# import company as a new partner +# company_address data used to add address to new partner +# +# edi_document is a dict to have company datas +# edi_document = { +# 'company_address': { +# 'street': True, +# 'street2': True, +# 'zip': True, +# 'city': True, +# 'state_id': True, +# 'country_id': True, +# 'email': True, +# 'phone': True, +# +# }, +# 'company_id': True, +# } +# values is a dict to have other datas of partner which are need to import of partner record +# values = { +# 'customer': True, +# 'supplier': True, +# } +# """ +# if values is None: +# values = {} +# partner_model = 'res.partner' +# partner_pool = self.pool.get(partner_model) +# xml_id = edi_document['company_id'][0] +# company_address = edi_document.get('company_address', False) +# partner_name = edi_document['company_id'][1] +# partner = partner_pool.edi_get_object(cr, uid, xml_id, partner_model, context=context) +# if not partner: +# partner = partner_pool.edi_get_object_by_name(cr, uid, partner_name, partner_model, context=context) +# +# if partner: + #FIXME +# record_xml = partner_pool._get_external_id(cr, uid, [partner.id], context=context) +# if record_xml: +# module, xml_id = record_xml +# xml_id = '%s.%s' % (module, xml_id) +# +# edi_document_partner = { +# '__model': partner_model, +# '__id' : xml_id, +# 'name' : partner_name, +# } +# if company_address: +# edi_document_partner['address'] = [company_address] +# +# edi_document_partner.update(values) +# return partner_pool.edi_import(cr, uid, edi_document_partner, context=context) +# +# def edi_export_address(self, cr, uid, records, edi_address_struct=None, context=None): +# """Return a dict representation of the address of each company record, suitable for +# inclusion in an EDI document, and matching the given edi_address_struct if provided. +# +# :param list(browse_record) records: list of companies to export +# :rtype: list(dict) +# :return: list of dicts, where each dict contains the address representation for +# the company record as the same index in ``records``. +# """ +# if context is None: +# context = {} +# res_partner = self.pool.get('res.partner') +# res_partner_address = self.pool.get('res.partner.address') +# if edi_address_struct is None: +# edi_address_struct = { +# 'street': True, +# 'street2': True, +# 'zip': True, +# 'city': True, +# 'state_id': True, +# 'country_id': True, +# 'email': True, +# 'phone': True, +# } +# results = [] +# for company in records: +# res = res_partner.address_get(cr, uid, [company.partner_id.id], ['default', 'contact', 'invoice']) +# addr_id = res['invoice'] or res['contact'] or res['default'] +# result = {} +# if addr_id: +# address = res_partner_address.browse(cr, uid, addr_id, context=context) +# result = res_partner_address.edi_export(cr, uid, [address], edi_struct=edi_address_struct, context=ctx)[0] +# results.append(result) +# return results + diff --git a/addons/edi/models/res_partner.py b/addons/edi/models/res_partner.py new file mode 100644 index 00000000000..e73badf226c --- /dev/null +++ b/addons/edi/models/res_partner.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Management Solution +# Copyright (C) 2004-2009 Tiny SPRL (). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## +from osv import fields,osv +from edi import EDIMixin + +RES_PARTNER_ADDRESS_EDI_STRUCT = { + 'name': True, + 'email': True, + 'street': True, + 'street2': True, + 'zip': True, + 'city': True, + 'country_id': True, + 'state_id': True, + 'phone': True, + 'fax': True, + 'mobile': True, +} + +RES_PARTNER_EDI_STRUCT = { + 'name': True, + 'ref': True, + 'lang': True, + 'website': True, + 'address': RES_PARTNER_ADDRESS_EDI_STRUCT +} + +class res_partner(osv.osv, EDIMixin): + _inherit = "res.partner" + + def edi_export(self, cr, uid, records, edi_struct=None, context=None): + return super(res_partner,self).edi_export(cr, uid, records, + dict(RES_PARTNER_EDI_STRUCT), + context=context) + +class res_partner_address(osv.osv, EDIMixin): + _inherit = "res.partner.address" + + def edi_export(self, cr, uid, records, edi_struct=None, context=None): + return super(res_partner_address,self).edi_export(cr, uid, records, + dict(RES_PARTNER_ADDRESS_EDI_STRUCT), + context=context) + +class res_partner_bank(osv.osv, EDIMixin): + _inherit = "res.partner.bank" + diff --git a/addons/edi/test/test_edi_documents.yml b/addons/edi/test/test_edi_documents.yml new file mode 100644 index 00000000000..c9695af73e1 --- /dev/null +++ b/addons/edi/test/test_edi_documents.yml @@ -0,0 +1,43 @@ +- + In order to test the basic EDI system, I will export a partner, + modify the exported EDI document to add an attachment and change + the data, and then re-import it and re-export it. + + with an attached file, check the result, the alter the data + and reimport it. +- + !python {model: ir.edi.document}: | + import json + partner_obj = self.pool.get('res.partner') + tokens = self.export_edi(cr, uid, [partner_obj.browse(cr, uid, ref('base.res_partner_agrolait'))]) + doc = self.get_document(cr, uid, tokens[0], context=context) + edi_doc, = json.loads(doc) + + # check content of the document + assert edi_doc.get('__id').endswith('.res_partner_agrolait'), 'Incorrect external ID' + assert edi_doc.get('__model') == 'res.partner', 'Incorrect/Missing __model' + assert edi_doc.get('__module') == 'base', 'Incorrect/Missing __module' + assert edi_doc.get('__last_update'), 'Missing __last_update' + + # try to import the document after changing the name and id, and attaching a + # file, and check that a new partner is returned + edi_doc['__id'] = 'base:xxd37f8a-xx55-11e0-xxdd-xx81124c8b50.res_partner_xxx' + edi_doc['name'] = 'AgroMilk' + attachment = { + 'name': 'Test file', + 'file_name': 'test.png', + 'content': 'iVBORw0KGgoAAAANSUhEUgAAAA4AAAAKCAYAAACE2W/HAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9oIDQ84BkjLWAYAAACNSURBVCjPjZKxDUJBDEOf/wbUbEHDhkxAzRjUDECBhMQYjIApuPBNpK+PpUinOPYll5Nt1iCJXifbSPpJZtHgXLUdu0FWpMEt87a/xtsmqjjNetPFyhuWYFuj7RcgQBNwXNGdw2CqY85yXWi5z7YBnmRyEHvg0YSXrLE9P30nwugOHHr+s5va4x+fofAGm1+JjnJICm0AAAAASUVORK5CYII=', + } + edi_doc['__attachments'] = [attachment] + doc = json.dumps([edi_doc]) + result, = self.import_edi(cr, uid, edi_document=doc) + assert result[0] == 'res.partner' and result[1] > ref('base.res_partner_agrolait'),\ + "Expected (%r,> %r) after import 1, got %r" % ('res.partner', ref('base.res_partner_agrolait'), result) + + # export the same partner we just created, and see if the output matches the input + tokens = self.export_edi(cr, uid, [partner_obj.browse(cr, uid, result[1])]) + doc_output = self.get_document(cr, uid, tokens[0], context=context) + edi_doc_output, = json.loads(doc_output) + for attribute in ('__model', '__module', '__id', 'name', '__attachments'): + assert edi_doc_output.get(attribute) == edi_doc.get(attribute), \ + 'Incorrect value for %s, expected %r, got %r' % (attribute, edi_doc.get(attribute), edi_doc_output.get(attribute)) \ No newline at end of file