c166eb9292a8f04200e8d7c2b2d2f5669c50ee96
[photoprint.git] / order.py
1 # -*- coding: utf-8 -*-
2 ############################################################
3 # Copyright © 2009 Benoît PIN <pinbe@luxia.fr> #
4 # Cliché - http://luxia.fr #
5 # #
6 # This program is free software; you can redistribute it #
7 # and/or modify it under the terms of the Creative Commons #
8 # "Attribution-Noncommercial 2.0 Generic" #
9 # http://creativecommons.org/licenses/by-nc/2.0/ #
10 ############################################################
11 """
12 Print order classes
13
14 $Id: order.py 1357 2009-09-07 16:06:05Z pin $
15 $URL: http://svn.luxia.fr/svn/labo/projects/zope/photoprint/trunk/order.py $
16 """
17
18 from Globals import InitializeClass, PersistentMapping, Persistent
19 from Acquisition import Implicit
20 from AccessControl import ClassSecurityInfo
21 from AccessControl.requestmethod import postonly
22 from zope.interface import implements
23 from zope.component.factory import Factory
24 from OFS.SimpleItem import SimpleItem
25 from ZTUtils import make_query
26 from Products.CMFCore.PortalContent import PortalContent
27 from Products.CMFCore.permissions import ModifyPortalContent, View
28 from Products.CMFCore.utils import getToolByName, getUtilityByInterfaceName
29 from Products.CMFDefault.DublinCore import DefaultDublinCoreImpl
30 from Products.Plinn.utils import getPreferredLanguages
31 from interfaces import IPrintOrderTemplate, IPrintOrder
32 from permissions import ManagePrintOrderTemplate, ManagePrintOrders
33 from price import Price
34 from utils import Message as _
35 from utils import translate
36 from xml.dom.minidom import Document
37 from tool import COPIES_COUNTERS
38 from App.config import getConfiguration
39 try :
40 from Products.cyberplus import CyberplusConfig
41 from Products.cyberplus import CyberplusRequester
42 from Products.cyberplus import CyberplusResponder
43 from Products.cyberplus import LANGUAGE_VALUES as CYBERPLUS_LANGUAGES
44 except ImportError:
45 pass
46 from logging import getLogger
47 console = getLogger('Products.photoprint.order')
48
49
50 def _getCyberplusConfig() :
51 zopeConf = getConfiguration()
52 try :
53 conf = zopeConf.product_config['cyberplus']
54 except KeyError :
55 EnvironmentError("No cyberplus configuration found in Zope environment.")
56
57 merchant_id = conf['merchant_id']
58 bin_path = conf['bin_path']
59 path_file = conf['path_file']
60 merchant_country = conf['merchant_country']
61
62 config = CyberplusConfig(merchant_id,
63 bin_path,
64 path_file,
65 merchant_country=merchant_country)
66 return config
67
68
69 class PrintOrderTemplate(SimpleItem) :
70 """
71 predefined print order
72 """
73 implements(IPrintOrderTemplate)
74
75 security = ClassSecurityInfo()
76
77 def __init__(self
78 , id
79 , title=''
80 , description=''
81 , productReference=''
82 , maxCopies=0
83 , price=0
84 , VATRate=0) :
85 self.id = id
86 self.title = title
87 self.description = description
88 self.productReference = productReference
89 self.maxCopies = maxCopies # 0 means unlimited
90 self.price = Price(price, VATRate)
91
92 security.declareProtected(ManagePrintOrderTemplate, 'edit')
93 def edit( self
94 , title=''
95 , description=''
96 , productReference=''
97 , maxCopies=0
98 , price=0
99 , VATRate=0 ) :
100 self.title = title
101 self.description = description
102 self.productReference = productReference
103 self.maxCopies = maxCopies
104 self.price = Price(price, VATRate)
105
106 security.declareProtected(ManagePrintOrderTemplate, 'formWidgetData')
107 def formWidgetData(self, REQUEST=None, RESPONSE=None):
108 """formWidgetData documentation
109 """
110 d = Document()
111 d.encoding = 'utf-8'
112 root = d.createElement('formdata')
113 d.appendChild(root)
114
115 def gua(name) :
116 return str(getattr(self, name, '')).decode('utf-8')
117
118 id = d.createElement('id')
119 id.appendChild(d.createTextNode(self.getId()))
120 root.appendChild(id)
121
122 title = d.createElement('title')
123 title.appendChild(d.createTextNode(gua('title')))
124 root.appendChild(title)
125
126 description = d.createElement('description')
127 description.appendChild(d.createTextNode(gua('description')))
128 root.appendChild(description)
129
130 productReference = d.createElement('productReference')
131 productReference.appendChild(d.createTextNode(gua('productReference')))
132 root.appendChild(productReference)
133
134 maxCopies = d.createElement('maxCopies')
135 maxCopies.appendChild(d.createTextNode(str(self.maxCopies)))
136 root.appendChild(maxCopies)
137
138 price = d.createElement('price')
139 price.appendChild(d.createTextNode(str(self.price.taxed)))
140 root.appendChild(price)
141
142 vatrate = d.createElement('VATRate')
143 vatrate.appendChild(d.createTextNode(str(self.price.vat)))
144 root.appendChild(vatrate)
145
146 if RESPONSE is not None :
147 RESPONSE.setHeader('content-type', 'text/xml; charset=utf-8')
148
149 manager = getToolByName(self, 'caching_policy_manager', None)
150 if manager is not None:
151 view_name = 'formWidgetData'
152 headers = manager.getHTTPCachingHeaders(
153 self, view_name, {}
154 )
155
156 for key, value in headers:
157 if key == 'ETag':
158 RESPONSE.setHeader(key, value, literal=1)
159 else:
160 RESPONSE.setHeader(key, value)
161 if headers:
162 RESPONSE.setHeader('X-Cache-Headers-Set-By',
163 'CachingPolicyManager: %s' %
164 '/'.join(manager.getPhysicalPath()))
165
166
167 return d.toxml('utf-8')
168
169
170 InitializeClass(PrintOrderTemplate)
171 PrintOrderTemplateFactory = Factory(PrintOrderTemplate)
172
173 class PrintOrder(PortalContent, DefaultDublinCoreImpl) :
174
175 implements(IPrintOrder)
176 security = ClassSecurityInfo()
177
178 def __init__( self, id) :
179 DefaultDublinCoreImpl.__init__(self)
180 self.id = id
181 self.items = []
182 self.quantity = 0
183 self.price = Price(0, 0)
184 # billing and shipping addresses
185 self.billing = PersistentMapping()
186 self.shipping = PersistentMapping()
187 self.shippingFees = Price(0,0)
188 self._paymentResponse = PersistentMapping()
189
190 @property
191 def amountWithFees(self) :
192 return self.price + self.shippingFees
193
194
195 security.declareProtected(ModifyPortalContent, 'editBilling')
196 def editBilling(self
197 , name
198 , address
199 , city
200 , zipcode
201 , country
202 , phone) :
203 self.billing['name'] = name
204 self.billing['address'] = address
205 self.billing['city'] = city
206 self.billing['zipcode'] = zipcode
207 self.billing['country'] = country
208 self.billing['phone'] = phone
209
210 security.declareProtected(ModifyPortalContent, 'editShipping')
211 def editShipping(self, name, address, city, zipcode, country) :
212 self.shipping['name'] = name
213 self.shipping['address'] = address
214 self.shipping['city'] = city
215 self.shipping['zipcode'] = zipcode
216 self.shipping['country'] = country
217
218 security.declarePrivate('loadCart')
219 def loadCart(self, cart):
220 pptool = getToolByName(self, 'portal_photo_print')
221 uidh = getToolByName(self, 'portal_uidhandler')
222 mtool = getToolByName(self, 'portal_membership')
223
224 items = []
225 for item in cart :
226 photo = uidh.getObject(item['cmf_uid'])
227 pOptions = pptool.getPrintingOptionsContainerFor(photo)
228 template = getattr(pOptions, item['printing_template'])
229
230 reference = template.productReference
231 quantity = item['quantity']
232 uPrice = template.price
233 self.quantity += quantity
234
235 d = {'cmf_uid' : item['cmf_uid']
236 ,'url' : photo.absolute_url()
237 ,'title' : template.title
238 ,'description' : template.description
239 ,'unit_price' : Price(uPrice._taxed, uPrice._rate)
240 ,'quantity' : quantity
241 ,'productReference' : reference
242 }
243 items.append(d)
244 self.price += uPrice * quantity
245 # confirm counters
246 if template.maxCopies :
247 counters = getattr(photo, COPIES_COUNTERS)
248 counters.confirm(reference, quantity)
249
250 self.items = tuple(items)
251
252 member = mtool.getAuthenticatedMember()
253 mg = lambda name : member.getProperty(name, '')
254 billing = {'name' : member.getMemberFullName(nameBefore=0)
255 ,'address' : mg('billing_address')
256 ,'city' : mg('billing_city')
257 ,'zipcode' : mg('billing_zipcode')
258 ,'country' : mg('country')
259 ,'phone' : mg('phone') }
260 self.editBilling(**billing)
261
262 sg = lambda name : cart._shippingInfo.get(name, '')
263 shipping = {'name' : sg('shipping_fullname')
264 ,'address' : sg('shipping_address')
265 ,'city' : sg('shipping_city')
266 ,'zipcode' : sg('shipping_zipcode')
267 ,'country' : sg('shipping_country')}
268 self.editShipping(**shipping)
269
270 self.shippingFees = pptool.getShippingFeesFor(shippable=self)
271
272 cart._confirmed = True
273 cart.pendingOrderPath = self.getPhysicalPath()
274
275 security.declareProtected(ManagePrintOrders, 'resetCopiesCounters')
276 def resetCopiesCounters(self) :
277 pptool = getToolByName(self, 'portal_photo_print')
278 uidh = getToolByName(self, 'portal_uidhandler')
279
280 for item in self.items :
281 photo = uidh.getObject(item['cmf_uid'])
282 counters = getattr(photo, COPIES_COUNTERS, None)
283 if counters :
284 counters.cancel(item['productReference'],
285 item['quantity'])
286
287 security.declareProtected(View, 'getPaymentRequest')
288 def getPaymentRequest(self) :
289 config = _getCyberplusConfig()
290 requester = CyberplusRequester(config)
291 hereurl = self.absolute_url()
292 amount = self.price + self.shippingFees
293 amount = amount.getValues()['taxed']
294 amount = amount * 100
295 amount = str(int(round(amount, 0)))
296 pptool = getToolByName(self, 'portal_photo_print')
297 transaction_id = pptool.getNextTransactionId()
298
299 userLanguages = getPreferredLanguages(self)
300 for pref in userLanguages :
301 lang = pref.split('-')[0]
302 if lang in CYBERPLUS_LANGUAGES :
303 break
304 else :
305 lang = 'en'
306
307 options = { 'amount': amount
308 ,'cancel_return_url' : '%s/paymentCancelHandler' % hereurl
309 ,'normal_return_url' : '%s/paymentManualResponseHandler' % hereurl
310 ,'automatic_response_url' :'%s/paymentAutoResponseHandler' % hereurl
311 ,'transaction_id' : transaction_id
312 ,'order_id' : self.getId()
313 ,'language' : lang
314 }
315 req = requester.generateRequest(options)
316 return req
317
318 def _decodeCyberplusResponse(self, form) :
319 config = _getCyberplusConfig()
320 responder = CyberplusResponder(config)
321 response = responder.getResponse(form)
322 return response
323
324 def _compareWithAutoResponse(self, manu) :
325 keys = manu.keys()
326 auto = self._paymentResponse
327 autoKeys = auto.keys()
328 if len(keys) != len(autoKeys) :
329 console.warn('Manual has not the same keys.\nauto: %r\nmanual: %r' % \
330 (sorted(autoKeys), sorted(keys)))
331 else :
332 for k, v in manu.items() :
333 if not auto.has_key(k) :
334 console.warn('%r field only found in manual response.' % k)
335 else :
336 if v != auto[k] :
337 console.warn('data mismatch for %r\nauto: %r\nmanual: %r' % (k, auto[k], v))
338
339 def _checkOrderId(self, response) :
340 expected = self.getId()
341 assert expected == response['order_id'], \
342 "Cyberplus response transaction_id doesn't match the order object:\n" \
343 "expected: %s\n" \
344 "found: %s" % (expected, response['transaction_id'])
345
346 def _executeOrderWfTransition(self, response) :
347 if CyberplusResponder.transactionAccepted(response) :
348 wfaction = 'auto_accept_payment'
349 elif CyberplusResponder.transactionRefused(response) :
350 self.resetCopiesCounters()
351 wfaction = 'auto_refuse_payment'
352 elif CyberplusResponder.transactionCanceled(response) :
353 wfaction = 'auto_cancel_order'
354 else :
355 # transaction failed
356 wfaction = 'auto_transaction_failed'
357
358 wtool = getToolByName(self, 'portal_workflow')
359 wf = wtool.getWorkflowById('order_workflow')
360 tdef = wf.transitions.get(wfaction)
361 wf._changeStateOf(self, tdef)
362 wtool._reindexWorkflowVariables(self)
363
364 security.declarePublic('paymentAutoResponseHandler')
365 @postonly
366 def paymentAutoResponseHandler(self, REQUEST) :
367 """\
368 Handle cyberplus payment auto response.
369 """
370 response = self._decodeCyberplusResponse(REQUEST.form)
371 self._checkOrderId(response)
372 self._paymentResponse.update(response)
373 self._executeOrderWfTransition(response)
374
375 @postonly
376 def paymentManualResponseHandler(self, REQUEST) :
377 """\
378 Handle cyberplus payment manual response.
379 """
380 response = self._decodeCyberplusResponse(REQUEST.form)
381 self._checkOrderId(response)
382
383 autoResponse = self._paymentResponse
384 if not autoResponse :
385 console.warn('Manual response handled before auto response at %s' % '/'.join(self.getPhysicalPath()))
386 self._paymentResponse.update(response)
387 self._executeOrderWfTransition(response)
388 else :
389 self._compareWithAutoResponse(response)
390
391 url = '%s?%s' % (self.absolute_url(),
392 make_query(portal_status_message=translate('Your payment is complete.', self).encode('utf-8'))
393 )
394 return REQUEST.RESPONSE.redirect(url)
395
396 @postonly
397 def paymentCancelHandler(self, REQUEST) :
398 """\
399 Handle cyberplus cancel response.
400 This handler can be invoqued in two cases:
401 - the user cancel the payment form
402 - the payment transaction has been refused
403 """
404 response = self._decodeCyberplusResponse(REQUEST.form)
405 self._checkOrderId(response)
406
407 if self._paymentResponse :
408 # normaly, it happens when the transaction is refused by cyberplus.
409 self._compareWithAutoResponse(response)
410
411
412 if CyberplusResponder.transactionRefused(response) :
413 if not self._paymentResponse :
414 console.warn('Manual response handled before auto response at %s' % '/'.join(self.getPhysicalPath()))
415 self._paymentResponse.update(response)
416 self._executeOrderWfTransition(response)
417
418 msg = 'Your payment has been refused.'
419
420 else :
421 self._executeOrderWfTransition(response)
422 msg = 'Your payment has been canceled. You will be able to pay later.'
423
424 url = '%s?%s' % (self.absolute_url(),
425 make_query(portal_status_message= \
426 translate(msg, self).encode('utf-8'))
427 )
428 return REQUEST.RESPONSE.redirect(url)
429
430
431 def getCustomerSummary(self) :
432 ' '
433 return {'quantity':self.quantity,
434 'price':self.price}
435
436
437 InitializeClass(PrintOrder)
438 PrintOrderFactory = Factory(PrintOrder)
439
440
441 class CopiesCounters(Persistent, Implicit) :
442
443 def __init__(self):
444 self._mapping = PersistentMapping()
445
446 def getBrowserId(self):
447 sdm = self.session_data_manager
448 bim = sdm.getBrowserIdManager()
449 browserId = bim.getBrowserId(create=1)
450 return browserId
451
452 def _checkBrowserId(self, browserId) :
453 sdm = self.session_data_manager
454 sd = sdm.getSessionDataByKey(browserId)
455 return not not sd
456
457 def __setitem__(self, reference, count) :
458 if not self._mapping.has_key(reference):
459 self._mapping[reference] = PersistentMapping()
460 self._mapping[reference]['pending'] = PersistentMapping()
461 self._mapping[reference]['confirmed'] = 0
462
463 globalCount = self[reference]
464 delta = count - globalCount
465 bid = self.getBrowserId()
466 if not self._mapping[reference]['pending'].has_key(bid) :
467 self._mapping[reference]['pending'][bid] = delta
468 else :
469 self._mapping[reference]['pending'][bid] += delta
470
471
472 def __getitem__(self, reference) :
473 item = self._mapping[reference]
474 globalCount = item['confirmed']
475
476 for browserId, count in item['pending'].items() :
477 if self._checkBrowserId(browserId) :
478 globalCount += count
479 else :
480 del self._mapping[reference]['pending'][browserId]
481
482 return globalCount
483
484 def get(self, reference, default=0) :
485 if self._mapping.has_key(reference) :
486 return self[reference]
487 else :
488 return default
489
490 def getPendingCounter(self, reference) :
491 bid = self.getBrowserId()
492 if not self._checkBrowserId(bid) :
493 console.warn('BrowserId not found: %s' % bid)
494 return 0
495
496 count = self._mapping[reference]['pending'].get(bid, None)
497 if count is None :
498 console.warn('No pending data found for browserId %s' % bid)
499 return 0
500 else :
501 return count
502
503 def confirm(self, reference, quantity) :
504 pending = self.getPendingCounter(reference)
505 if pending != quantity :
506 console.warn('Pending quantity mismatch with the confirmed value: (%d, %d)' % (pending, quantity))
507
508 browserId = self.getBrowserId()
509 if self._mapping[reference]['pending'].has_key(browserId) :
510 del self._mapping[reference]['pending'][browserId]
511 self._mapping[reference]['confirmed'] += quantity
512
513 def cancel(self, reference, quantity) :
514 self._mapping[reference]['confirmed'] -= quantity
515
516 def __str__(self):
517 return str(self._mapping)