Merge lp:~akretion-team/openerp-connector-magento/openerp-connector-magento-base-catalog-export into lp:~openerp-connector-core-editors/openerp-connector-magento/7.0

Proposed by Sébastien BEAU - http://www.akretion.com
Status: Superseded
Proposed branch: lp:~akretion-team/openerp-connector-magento/openerp-connector-magento-base-catalog-export
Merge into: lp:~openerp-connector-core-editors/openerp-connector-magento/7.0
Diff against target: 516 lines (+296/-54)
7 files modified
magentoerpconnect/consumer.py (+16/-0)
magentoerpconnect/product.py (+20/-0)
magentoerpconnect/product_category.py (+35/-1)
magentoerpconnect/product_view.xml (+3/-0)
magentoerpconnect/unit/export_synchronizer.py (+222/-1)
magentoerpconnect_catalog/__init__.py (+0/-1)
magentoerpconnect_catalog/__openerp__.py (+0/-51)
To merge this branch: bzr merge lp:~akretion-team/openerp-connector-magento/openerp-connector-magento-base-catalog-export
Reviewer Review Type Date Requested Status
OpenERP Connector Core Editors Pending
Review via email: mp+223604@code.launchpad.net

This proposal has been superseded by a proposal from 2014-06-23.

Description of the change

This is change done on the existing stable module for being compatible with the magentoerpconnect_catalog and magentoerpconnect_product_variant modules.

Not ready for being merge yet

To post a comment you must log in.
1005. By Sébastien BEAU - http://www.akretion.com

[MERGE] merge lock branch from Guewen, this will prevent job to export twice the same record

Unmerged revisions

1005. By Sébastien BEAU - http://www.akretion.com

[MERGE] merge lock branch from Guewen, this will prevent job to export twice the same record

1004. By Sébastien BEAU - http://www.akretion.com

[REF] remove magentoerpconnect_catalog as the developpement is manage in a separated branch, will be merged soon

1003. By Sébastien BEAU - http://www.akretion.com

[IMP] commit work done by Augustin Cisterne-Kaas <email address hidden>, David Beal <email address hidden> Chafique Delli <email address hidden> and myself. This have been extracted from working branch on catalog export. Add the delay_unlink_all_binding, add fields visibility/status at the import. Add backendAdapter method, no refactor have been done yet

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'magentoerpconnect/consumer.py'
2--- magentoerpconnect/consumer.py 2013-10-09 19:41:58 +0000
3+++ magentoerpconnect/consumer.py 2014-06-23 09:58:18 +0000
4@@ -66,6 +66,22 @@
5 fields=fields)
6
7
8+@on_record_unlink(model_names=_MODEL_NAMES)
9+def delay_unlink_all_bindings(session, model_name, record_id):
10+ """ Delay jobs which delete all the bindings of a record.
11+
12+ In this case, it is called on records of normal models and will delay
13+ the deletion for all the bindings.
14+ """
15+ if session.context.get('connector_no_export'):
16+ return
17+ model = session.pool.get(model_name)
18+ record = model.browse(session.cr, session.uid,
19+ record_id, context=session.context)
20+ for binding in record.magento_bind_ids:
21+ delay_unlink(session, binding._model._name, binding.id)
22+
23+
24 @on_record_unlink(model_names=_BIND_MODEL_NAMES)
25 def delay_unlink(session, model_name, record_id):
26 """ Delay a job which delete a record on Magento.
27
28=== modified file 'magentoerpconnect/product.py'
29--- magentoerpconnect/product.py 2014-06-14 20:30:26 +0000
30+++ magentoerpconnect/product.py 2014-06-23 09:58:18 +0000
31@@ -88,6 +88,18 @@
32 'product_type': fields.selection(_product_type_get,
33 'Magento Product Type',
34 required=True),
35+ 'visibility': fields.selection(
36+ [('1', 'Not Visible Individually'),
37+ ('2', 'Catalog'),
38+ ('3', 'Search'),
39+ ('4', 'Catalog, Search')],
40+ string='Visibility in Magento',
41+ required=True),
42+ 'status': fields.selection(
43+ [('1', 'Enabled'),
44+ ('2', 'Disabled')],
45+ string='Status in Magento',
46+ required=True),
47 'manage_stock': fields.selection(
48 [('use_default', 'Use Default Config'),
49 ('no', 'Do Not Manage Stock'),
50@@ -116,6 +128,8 @@
51 'manage_stock': 'use_default',
52 'backorders': 'use_default',
53 'no_stock_sync': False,
54+ 'visibility': '4',
55+ 'status': '1',
56 }
57
58 _sql_constraints = [
59@@ -205,6 +219,10 @@
60 in self._call('%s.list' % self._magento_model,
61 [filters] if filters else [{}])]
62
63+ def create(self, data):
64+ return self._call('%s.create'% self._magento_model,
65+ [data.pop('product_type'),data.pop('attrset'),data.pop('sku'),data])
66+
67 def read(self, id, storeview_id=None, attributes=None):
68 """ Returns the information of a record
69
70@@ -429,6 +447,8 @@
71 ('type_id', 'product_type'),
72 (normalize_datetime('created_at'), 'created_at'),
73 (normalize_datetime('updated_at'), 'updated_at'),
74+ ('visibility', 'visibility'),
75+ ('status', 'status')
76 ]
77
78 @mapping
79
80=== modified file 'magentoerpconnect/product_category.py'
81--- magentoerpconnect/product_category.py 2014-05-26 09:37:00 +0000
82+++ magentoerpconnect/product_category.py 2014-06-23 09:58:18 +0000
83@@ -25,7 +25,8 @@
84 from openerp.addons.connector.unit.mapper import (mapping,
85 ImportMapper
86 )
87-from openerp.addons.connector.exception import IDMissingInBackend
88+from openerp.addons.connector.exception import (MappingError,
89+ IDMissingInBackend)
90 from .unit.backend_adapter import GenericAdapter
91 from .unit.import_synchronizer import (DelayedBatchImport,
92 MagentoImportSynchronizer,
93@@ -49,6 +50,8 @@
94 required=True,
95 ondelete='cascade'),
96 'description': fields.text('Description', translate=True),
97+ 'is_active': fields.boolean('Active in magento'),
98+ 'include_in_menu': fields.boolean('Include in magento menu'),
99 'magento_parent_id': fields.many2one(
100 'magento.product.category',
101 string='Magento Parent Category',
102@@ -59,6 +62,11 @@
103 string='Magento Child Categories'),
104 }
105
106+ _defaults = {
107+ 'is_active': True,
108+ 'include_in_menu': False,
109+ }
110+
111 _sql_constraints = [
112 ('magento_uniq', 'unique(backend_id, magento_id)',
113 'A product category with same ID on Magento already exists.'),
114@@ -100,6 +108,30 @@
115 else:
116 raise
117
118+ def update_image(self, data):
119+ for name in ['image', 'thumbnail']:
120+ if data.get(name + '_binary'):
121+ img = self._call('%s.create'% 'ol_catalog_category_media',
122+ [data[name], data.pop(name + '_binary')])
123+ if img == 'Error in file creation':
124+ #TODO FIX error management
125+ raise Exception("Image creation: ",
126+ "Magento tried to insert image (%s) but there is "
127+ "no sufficient grants in the folder "
128+ "'media/catalog/category' if it exists" %data[name])
129+ return True
130+
131+ def create(self, data):
132+ self.update_image(data)
133+ return self._call('%s.create'% self._magento_model,
134+ [data['parent_id'],data])
135+
136+ def write(self, id, data, storeview=None):
137+ """ Update records on the external system """
138+ self.update_image(data)
139+ return self._call('%s.update' % self._magento_model,
140+ [int(id), data, storeview])
141+
142 def search(self, filters=None, from_date=None):
143 """ Search records according to some criterias and returns a
144 list of ids
145@@ -217,6 +249,8 @@
146
147 direct = [
148 ('description', 'description'),
149+ ('is_active','is_active'),
150+ ('include_in_menu','include_in_menu')
151 ]
152
153 @mapping
154
155=== modified file 'magentoerpconnect/product_view.xml'
156--- magentoerpconnect/product_view.xml 2014-05-26 09:32:38 +0000
157+++ magentoerpconnect/product_view.xml 2014-06-23 09:58:18 +0000
158@@ -77,7 +77,10 @@
159 <field name="magento_id"/>
160 <field name="created_at" readonly="1"/>
161 <field name="updated_at" readonly="1"/>
162+ <field name="visibility" />
163+ <field name="status" />
164 <field name="product_type" readonly="1"/>
165+ <field name="website_ids" />
166 </group>
167 <group string="Inventory Options">
168 <field name="no_stock_sync"/>
169
170=== modified file 'magentoerpconnect/unit/export_synchronizer.py'
171--- magentoerpconnect/unit/export_synchronizer.py 2014-05-26 09:37:00 +0000
172+++ magentoerpconnect/unit/export_synchronizer.py 2014-06-23 09:58:18 +0000
173@@ -20,12 +20,19 @@
174 ##############################################################################
175
176 import logging
177+
178+from contextlib import contextmanager
179 from datetime import datetime
180+
181+import psycopg2
182+
183+from openerp import SUPERUSER_ID
184 from openerp.tools.translate import _
185 from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT
186 from openerp.addons.connector.queue.job import job, related_action
187 from openerp.addons.connector.unit.synchronizer import ExportSynchronizer
188-from openerp.addons.connector.exception import IDMissingInBackend
189+from openerp.addons.connector.exception import (IDMissingInBackend,
190+ RetryableJobError)
191 from .import_synchronizer import import_record
192 from ..connector import get_environment
193 from ..related_action import unwrap_binding
194@@ -96,6 +103,10 @@
195 """ Return the raw OpenERP data for ``self.binding_id`` """
196 return self.session.browse(self.model._name, self.binding_id)
197
198+ def _after_export(self):
199+ """ Run the after export"""
200+ return
201+
202 def run(self, binding_id, *args, **kwargs):
203 """ Run the synchronization
204
205@@ -116,6 +127,12 @@
206 result = self._run(*args, **kwargs)
207
208 self.binder.bind(self.magento_id, self.binding_id)
209+ # Commit so we keep the external ID when there are several
210+ # exports (due to dependencies) and one of them fails.
211+ # The commit will also release the lock acquired on the binding
212+ # record
213+ self.session.commit()
214+ self._after_export()
215 return result
216
217 def _run(self):
218@@ -134,10 +151,160 @@
219 super(MagentoExporter, self).__init__(environment)
220 self.binding_record = None
221
222+ def _lock(self):
223+ """ Lock the binding record.
224+
225+ Lock the binding record so we are sure that only one export
226+ job is running for this record if concurrent jobs have to export the
227+ same record.
228+
229+ When concurrent jobs try to export the same record, the first one
230+ will lock and proceed, the others will fail to lock and will be
231+ retried later.
232+
233+ This behavior works also when the export becomes multilevel
234+ with :meth:`_export_dependencies`. Each level will set its own lock
235+ on the binding record it has to export.
236+
237+ """
238+ sql = ("SELECT id FROM %s WHERE ID = %%s FOR UPDATE NOWAIT" %
239+ self.model._table)
240+ try:
241+ self.session.cr.execute(sql, (self.binding_id, ),
242+ log_exceptions=False)
243+ except psycopg2.OperationalError:
244+ _logger.info('A concurrent job is already exporting the same '
245+ 'record (%s with id %s). Job delayed later.',
246+ self.model._name, self.binding_id)
247+ raise RetryableJobError(
248+ 'A concurrent job is already exporting the same record '
249+ '(%s with id %s). The job will be retried later.' %
250+ (self.model._name, self.binding_id))
251+
252 def _has_to_skip(self):
253 """ Return True if the export can be skipped """
254 return False
255
256+ @contextmanager
257+ def _retry_unique_violation(self):
258+ """ Context manager: catch Unique constraint error and retry the
259+ job later.
260+
261+ When we execute several jobs workers concurrently, it happens
262+ that 2 jobs are creating the same record at the same time (binding
263+ record created by :meth:`_export_dependency`), resulting in:
264+
265+ IntegrityError: duplicate key value violates unique
266+ constraint "magento_product_product_openerp_uniq"
267+ DETAIL: Key (backend_id, openerp_id)=(1, 4851) already exists.
268+
269+ In that case, we'll retry the import just later.
270+
271+ .. warning:: The unique constraint must be created on the
272+ binding record to prevent 2 bindings to be created
273+ for the same Magento record.
274+
275+ """
276+ try:
277+ yield
278+ except psycopg2.IntegrityError as err:
279+ if err.pgcode == psycopg2.errorcodes.UNIQUE_VIOLATION:
280+ raise RetryableJobError(
281+ 'A database error caused the failure of the job:\n'
282+ '%s\n\n'
283+ 'Likely due to 2 concurrent jobs wanting to create '
284+ 'the same record. The job will be retried later.' % err)
285+ else:
286+ raise
287+
288+ def _export_dependency(self, relation, binding_model, exporter_class=None,
289+ binding_field='magento_bind_ids'):
290+ """
291+ Export a dependency. The exporter class is a subclass of
292+ ``MagentoExporter``. If a more precise class need to be defined,
293+ it can be passed to the ``exporter_class`` keyword argument.
294+
295+ .. warning:: a commit is done at the end of the export of each
296+ dependency. The reason for that is that we pushed a record
297+ on the backend and we absolutely have to keep its ID.
298+
299+ So you *must* take care not to modify the OpenERP
300+ database during an export, excepted when writing
301+ back the external ID or eventually to store
302+ external data that we have to keep on this side.
303+
304+ You should call this method only at the beginning
305+ of the exporter synchronization,
306+ in :meth:`~._export_dependencies`.
307+
308+ :param relation: record to export if not already exported
309+ :type relation: :py:class:`openerp.osv.orm.browse_record`
310+ :param binding_model: name of the binding model for the relation
311+ :type binding_model: str | unicode
312+ :param exporter_cls: :py:class:`openerp.addons.connector.connector.ConnectorUnit`
313+ class or parent class to use for the export.
314+ By default: MagentoExporter
315+ :type exporter_cls: :py:class:`openerp.addons.connector.connector.MetaConnectorUnit`
316+ :param binding_field: name of the one2many field on a normal
317+ record that points to the binding record
318+ (default: magento_bind_ids).
319+ It is used only when the relation is not
320+ a binding but is a normal record.
321+ :type binding_field: str | unicode
322+ """
323+ if not relation:
324+ return
325+ if exporter_class is None:
326+ exporter_class = MagentoExporter
327+ rel_binder = self.get_binder_for_model(binding_model)
328+ # wrap is typically True if the relation is for instance a
329+ # 'product.product' record but the binding model is
330+ # 'magento.product.product'
331+ wrap = relation._model._name != binding_model
332+
333+ if wrap and hasattr(relation, binding_field):
334+ domain = [('openerp_id', '=', relation.id),
335+ ('backend_id', '=', self.backend_record.id)]
336+ binding_ids = self.session.search(binding_model, domain)
337+ if binding_ids:
338+ assert len(binding_ids) == 1, (
339+ 'only 1 binding for a backend is '
340+ 'supported in _export_dependency')
341+ binding_id = binding_ids[0]
342+ # we are working with a unwrapped record (e.g.
343+ # product.category) and the binding does not exist yet.
344+ # Example: I created a product.product and its binding
345+ # magento.product.product and we are exporting it, but we need to
346+ # create the binding for the product.category on which it
347+ # depends.
348+ else:
349+ with self.session.change_context({'connector_no_export': True}):
350+ with self.session.change_user(SUPERUSER_ID):
351+ bind_values = {'backend_id': self.backend_record.id,
352+ 'openerp_id': relation.id}
353+ # If 2 jobs create it at the same time, retry
354+ # one later. A unique constraint (backend_id,
355+ # openerp_id) should exist on the binding model
356+ with self._retry_unique_violation():
357+ binding_id = self.session.create(binding_model,
358+ bind_values)
359+ # Eager commit to avoid having 2 jobs
360+ # exporting at the same time. The constraint
361+ # will pop if an other job already created
362+ # the same binding. It will be caught and
363+ # raise a RetryableJobError.
364+ self.session.commit()
365+ else:
366+ # If magento_bind_ids does not exist we are typically in a
367+ # "direct" binding (the binding record is the same record).
368+ # If wrap is True, relation is already a binding record.
369+ binding_id = relation.id
370+
371+ if rel_binder.to_backend(binding_id) is None:
372+ exporter = self.get_connector_unit_for_model(exporter_class,
373+ binding_model)
374+ exporter.run(binding_id)
375+
376 def _export_dependencies(self):
377 """ Export the dependencies for the record"""
378 return
379@@ -194,20 +361,74 @@
380 # export the missing linked resources
381 self._export_dependencies()
382
383+ # prevent other jobs to export the same record
384+ # will be released on commit (or rollback)
385+ self._lock()
386+
387 map_record = self._map_data()
388
389 if self.magento_id:
390 record = self._update_data(map_record, fields=fields)
391 if not record:
392 return _('Nothing to export.')
393+ # special check on data before export
394+ self._validate_data(record)
395 self._update(record)
396 else:
397 record = self._create_data(map_record, fields=fields)
398 if not record:
399 return _('Nothing to export.')
400 self.magento_id = self._create(record)
401+ self.session.cr.commit()
402 return _('Record exported with ID %s on Magento.') % self.magento_id
403
404+class MagentoTranslationExporter(MagentoExporter):
405+ """ A common flow for the exports record with translation to Magento """
406+
407+ def _get_translatable_field(self, fields):
408+ # find the translatable fields of the model
409+ # Note we consider that a translatable field in Magento
410+ # must be a translatable field in OpenERP and vice-versa
411+ # you can change this behaviour in your own module
412+ all_fields = self.model.fields_get(self.session.cr, self.session.uid,
413+ context=self.session.context)
414+
415+ translatable_fields = [field for field, attrs in all_fields.iteritems()
416+ if attrs.get('translate') and (not fields or field in fields)]
417+ return translatable_fields
418+
419+
420+ def _run(self, fields=None):
421+ default_lang = self.backend_record.default_lang_id
422+ session = self.session
423+ if session.context is None:
424+ session.context = {}
425+ session.context['lang'] = default_lang.code
426+ res = super(MagentoTranslationExporter, self)._run(fields)
427+
428+ storeview_ids = session.search(
429+ 'magento.storeview',
430+ [('backend_id', '=', self.backend_record.id)])
431+ storeviews = session.browse('magento.storeview', storeview_ids)
432+ lang_storeviews = [sv for sv in storeviews
433+ if sv.lang_id and sv.lang_id != default_lang]
434+ if lang_storeviews:
435+ translatable_fields = self._get_translatable_field(fields)
436+ if translatable_fields:
437+ for storeview in lang_storeviews:
438+ session.context['lang'] = storeview.lang_id.code
439+ self.binding_record = self._get_openerp_data()
440+ self._map_data(fields=translatable_fields)
441+ record = self.mapper.data
442+ if not record:
443+ return _('nothing to export.')
444+ # special check on data before export
445+ self._validate_data(record)
446+ binder = self.get_binder_for_model('magento.storeview')
447+ magento_storeview_id = binder.to_backend(storeview.id)
448+ self.backend_adapter.write(self.magento_id, record, magento_storeview_id)
449+ return res
450+
451
452 @job
453 @related_action(action=unwrap_binding)
454
455=== removed directory 'magentoerpconnect_catalog'
456=== removed file 'magentoerpconnect_catalog/__init__.py'
457--- magentoerpconnect_catalog/__init__.py 2013-03-19 15:41:34 +0000
458+++ magentoerpconnect_catalog/__init__.py 1970-01-01 00:00:00 +0000
459@@ -1,1 +0,0 @@
460-# -*- coding: utf-8 -*-
461
462=== removed file 'magentoerpconnect_catalog/__openerp__.py'
463--- magentoerpconnect_catalog/__openerp__.py 2013-03-19 15:41:34 +0000
464+++ magentoerpconnect_catalog/__openerp__.py 1970-01-01 00:00:00 +0000
465@@ -1,51 +0,0 @@
466-# -*- coding: utf-8 -*-
467-##############################################################################
468-#
469-# Author: Guewen Baconnier
470-# Copyright 2013 Camptocamp SA
471-# Copyright 2013 Akretion
472-#
473-# This program is free software: you can redistribute it and/or modify
474-# it under the terms of the GNU Affero General Public License as
475-# published by the Free Software Foundation, either version 3 of the
476-# License, or (at your option) any later version.
477-#
478-# This program is distributed in the hope that it will be useful,
479-# but WITHOUT ANY WARRANTY; without even the implied warranty of
480-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
481-# GNU Affero General Public License for more details.
482-#
483-# You should have received a copy of the GNU Affero General Public License
484-# along with this program. If not, see <http://www.gnu.org/licenses/>.
485-#
486-##############################################################################
487-
488-
489-{'name': 'Magento Connector - Catalog',
490- 'version': '2.0.0',
491- 'category': 'Connector',
492- 'depends': ['magentoerpconnect',
493- 'product_links',
494- 'product_images',
495- ],
496- 'author': 'MagentoERPconnect Core Editors',
497- 'license': 'AGPL-3',
498- 'website': 'https://launchpad.net/magentoerpconnect',
499- 'description': """
500-Magento Connector - Catalog
501-===========================
502-
503-Extension for **Magento Connector**, add management of the product's catalog:
504-
505-* product links
506-* product images
507-* export of products, categories, links and images
508-
509-""",
510- 'images': [],
511- 'demo': [],
512- 'data': [],
513- 'installable': True,
514- 'application': False,
515-}
516-