1 # -*- coding: utf-8 -*-
2 #######################################################################################
3 # Plinn - http://plinn.org #
4 # Copyright (C) 2009-2013 Benoît PIN <benoit.pin@ensmp.fr> #
6 # This program is free software; you can redistribute it and/or #
7 # modify it under the terms of the GNU General Public License #
8 # as published by the Free Software Foundation; either version 2 #
9 # of the License, or (at your option) any later version. #
11 # This program is distributed in the hope that it will be useful, #
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of #
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
14 # GNU General Public License for more details. #
16 # You should have received a copy of the GNU General Public License #
17 # along with this program; if not, write to the Free Software #
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #
19 #######################################################################################
27 from Globals
import InitializeClass
, PersistentMapping
, Persistent
28 from Acquisition
import Implicit
29 from AccessControl
import ClassSecurityInfo
30 from AccessControl
.requestmethod
import postonly
31 from zope
.interface
import implements
32 from zope
.component
.factory
import Factory
33 from OFS
.SimpleItem
import SimpleItem
34 from ZTUtils
import make_query
35 from DateTime
import DateTime
36 from Products
.CMFCore
.PortalContent
import PortalContent
37 from Products
.CMFCore
.permissions
import ModifyPortalContent
, View
, ManagePortal
38 from Products
.CMFCore
.utils
import getToolByName
, getUtilityByInterfaceName
39 from Products
.CMFDefault
.DublinCore
import DefaultDublinCoreImpl
40 from Products
.Plinn
.utils
import getPreferredLanguages
41 from interfaces
import IPrintOrderTemplate
, IPrintOrder
42 from permissions
import ManagePrintOrderTemplate
, ManagePrintOrders
43 from price
import Price
44 from utils
import Message
as _
45 from utils
import translate
46 from xml
.dom
.minidom
import Document
47 from tool
import COPIES_COUNTERS
48 from App
.config
import getConfiguration
49 from paypal
.interface
import PayPalInterface
50 from logging
import getLogger
51 console
= getLogger('Products.photoprint.order')
54 def getPayPalConfig() :
55 zopeConf
= getConfiguration()
57 conf
= zopeConf
.product_config
['photoprint']
59 EnvironmentError("No photoprint configuration found in Zope environment.")
61 ppconf
= {'API_ENVIRONMENT' : conf
['paypal_api_environment'],
62 'API_USERNAME' : conf
['paypal_username'],
63 'API_PASSWORD' : conf
['paypal_password'],
64 'API_SIGNATURE' : conf
['paypal_signature']}
69 class PrintOrderTemplate(SimpleItem
) :
71 predefined print order
73 implements(IPrintOrderTemplate
)
75 security
= ClassSecurityInfo()
87 self
.description
= description
88 self
.productReference
= productReference
89 self
.maxCopies
= maxCopies
# 0 means unlimited
90 self
.price
= Price(price
, VATRate
)
92 security
.declareProtected(ManagePrintOrderTemplate
, 'edit')
101 self
.description
= description
102 self
.productReference
= productReference
103 self
.maxCopies
= maxCopies
104 self
.price
= Price(price
, VATRate
)
106 security
.declareProtected(ManagePrintOrderTemplate
, 'formWidgetData')
107 def formWidgetData(self
, REQUEST
=None, RESPONSE
=None):
108 """formWidgetData documentation
112 root
= d
.createElement('formdata')
116 return str(getattr(self
, name
, '')).decode('utf-8')
118 id = d
.createElement('id')
119 id.appendChild(d
.createTextNode(self
.getId()))
122 title
= d
.createElement('title')
123 title
.appendChild(d
.createTextNode(gua('title')))
124 root
.appendChild(title
)
126 description
= d
.createElement('description')
127 description
.appendChild(d
.createTextNode(gua('description')))
128 root
.appendChild(description
)
130 productReference
= d
.createElement('productReference')
131 productReference
.appendChild(d
.createTextNode(gua('productReference')))
132 root
.appendChild(productReference
)
134 maxCopies
= d
.createElement('maxCopies')
135 maxCopies
.appendChild(d
.createTextNode(str(self
.maxCopies
)))
136 root
.appendChild(maxCopies
)
138 price
= d
.createElement('price')
139 price
.appendChild(d
.createTextNode(str(self
.price
.taxed
)))
140 root
.appendChild(price
)
142 vatrate
= d
.createElement('VATRate')
143 vatrate
.appendChild(d
.createTextNode(str(self
.price
.vat
)))
144 root
.appendChild(vatrate
)
146 if RESPONSE
is not None :
147 RESPONSE
.setHeader('content-type', 'text/xml; charset=utf-8')
149 manager
= getToolByName(self
, 'caching_policy_manager', None)
150 if manager
is not None:
151 view_name
= 'formWidgetData'
152 headers
= manager
.getHTTPCachingHeaders(
156 for key
, value
in headers
:
158 RESPONSE
.setHeader(key
, value
, literal
=1)
160 RESPONSE
.setHeader(key
, value
)
162 RESPONSE
.setHeader('X-Cache-Headers-Set-By',
163 'CachingPolicyManager: %s' %
164 '/'.join(manager
.getPhysicalPath()))
167 return d
.toxml('utf-8')
170 InitializeClass(PrintOrderTemplate
)
171 PrintOrderTemplateFactory
= Factory(PrintOrderTemplate
)
173 class PrintOrder(PortalContent
, DefaultDublinCoreImpl
) :
175 implements(IPrintOrder
)
176 security
= ClassSecurityInfo()
178 def __init__( self
, id) :
179 DefaultDublinCoreImpl
.__init
__(self
)
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()
191 def amountWithFees(self
) :
192 return self
.price
+ self
.shippingFees
195 security
.declareProtected(ModifyPortalContent
, 'editBilling')
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
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
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')
226 photo
= uidh
.getObject(item
['cmf_uid'])
227 pOptions
= pptool
.getPrintingOptionsContainerFor(photo
)
228 template
= getattr(pOptions
, item
['printing_template'])
230 reference
= template
.productReference
231 quantity
= item
['quantity']
232 uPrice
= template
.price
233 self
.quantity
+= quantity
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
244 self
.price
+= uPrice
* quantity
246 if template
.maxCopies
:
247 counters
= getattr(photo
, COPIES_COUNTERS
)
248 counters
.confirm(reference
, quantity
)
250 self
.items
= tuple(items
)
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
)
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
)
270 self
.shippingFees
= pptool
.getShippingFeesFor(shippable
=self
)
272 cart
._confirmed
= True
273 cart
.pendingOrderPath
= self
.getPhysicalPath()
275 security
.declareProtected(ManagePrintOrders
, 'resetCopiesCounters')
276 def resetCopiesCounters(self
) :
277 pptool
= getToolByName(self
, 'portal_photo_print')
278 uidh
= getToolByName(self
, 'portal_uidhandler')
280 for item
in self
.items
:
281 photo
= uidh
.getObject(item
['cmf_uid'])
282 counters
= getattr(photo
, COPIES_COUNTERS
, None)
284 counters
.cancel(item
['productReference'],
288 def _initPayPalInterface(self
) :
289 config
= getPayPalConfig()
290 config
['API_AUTHENTICATION_MODE'] = '3TOKEN'
291 ppi
= PayPalInterface(**config
)
296 def recordifyPPResp(response
) :
298 d
['zopeTime'] = DateTime()
299 for k
, v
in response
.raw
.iteritems() :
307 security
.declareProtected(ModifyPortalContent
, 'ppSetExpressCheckout')
308 def ppSetExpressCheckout(self
) :
309 utool
= getUtilityByInterfaceName('Products.CMFCore.interfaces.IURLTool')
310 mtool
= getUtilityByInterfaceName('Products.CMFCore.interfaces.IMembershipTool')
312 portal
= utool
.getPortalObject()
313 member
= mtool
.getAuthenticatedMember()
315 options
= {#'PAYMENTREQUEST_0_AMT' : '99.55', # todo
316 'PAYMENTREQUEST_0_CURRENCYCODE' : 'EUR',
317 'PAYMENTREQUEST_0_PAYMENTACTION' : 'Sale',
318 'RETURNURL' : '%s/photoprint_order_confirm' % self
.absolute_url(),
319 'CANCELURL' : '%s/photoprint_order_cancel' % self
.absolute_url(),
321 'ALLOWNOTE' : 0, # The buyer is unable to enter a note to the merchant.
322 'HDRIMG' : '%s/logo.gif' % portal_url
,
323 'EMAIL' : member
.getProperty('email'),
324 'SOLUTIONTYPE' : 'Sole', # Buyer does not need to create a PayPal account to check out. This is referred to as PayPal Account Optional.
325 'LANDINGPAGE' : 'Billing', # Non-PayPal account
326 'BRANDNAME' : portal
.getProperty('title'),
327 'GIFTMESSAGEENABLE' : 0,
328 'GIFTRECEIPTENABLE' : 0,
329 'BUYEREMAILOPTINENABLE' : 0, # Do not enable buyer to provide email address.
330 'NOSHIPPING' : 1, # PayPal does not display shipping address fields whatsoever.
331 # 'PAYMENTREQUEST_0_NOTIFYURL' : TODO
333 'PAYMENTREQUEST_0_SHIPTONAME' : self
.billing
['name'],
334 'PAYMENTREQUEST_0_SHIPTOSTREET' : self
.billing
['address'],
335 'PAYMENTREQUEST_0_SHIPTOCITY' : self
.billing
['city'],
336 'PAYMENTREQUEST_0_SHIPTOZIP' : self
.billing
['zipcode'],
337 'PAYMENTREQUEST_0_SHIPTOPHONENUM' : self
.billing
['phone'],
340 if len(self
.items
) > 1 :
341 quantitySum
= reduce(lambda a
, b
: a
['quantity'] + b
['quantity'], self
.items
)
343 quantitySum
= self
.items
[0]['quantity']
344 total
= round(self
.amountWithFees
.getValues()['taxed'], 2)
346 options
['L_PAYMENTREQUEST_0_NAME0'] = 'Commande realis photo ref. %s' % self
.getId()
347 if quantitySum
== 1 :
348 options
['L_PAYMENTREQUEST_0_DESC0'] = "Commande d'un tirage photographique"
350 options
['L_PAYMENTREQUEST_0_DESC0'] = 'Commande de %d tirages photographiques' % quantitySum
351 options
['L_PAYMENTREQUEST_0_AMT0'] = total
352 options
['PAYMENTINFO_0_SHIPPINGAMT'] = round(self
.shippingFees
.getValues()['taxed'], 2)
353 # options['L_PAYMENTREQUEST_0_TAXAMT0'] = tax
354 # options['L_PAYMENTREQUEST_0_QTY%d' % n] = 1
355 options
['PAYMENTREQUEST_0_AMT'] = total
357 ppi
= self
._initPayPalInterface
()
358 response
= ppi
.set_express_checkout(**options
)
359 response
= PrintOrder
.recordifyPPResp(response
)
360 # self._paypalLog.append(response)
361 response
['url'] = ppi
.generate_express_checkout_redirect_url(response
['TOKEN'])
362 console
.info(options
)
363 console
.info(response
)
366 security
.declarePrivate('ppGetExpressCheckoutDetails')
367 def ppGetExpressCheckoutDetails(self
, token
) :
368 ppi
= self
._initPayPalInterface
()
369 response
= ppi
.get_express_checkout_details(TOKEN
=token
)
370 response
= PrintOrder
.recordifyPPResp(response
)
371 # self._paypalLog.append(response)
374 security
.declarePrivate('ppDoExpressCheckoutPayment')
375 def ppDoExpressCheckoutPayment(self
, token
, payerid
, amt
) :
376 ppi
= self
._initPayPalInterface
()
377 response
= ppi
.do_express_checkout_payment(PAYMENTREQUEST_0_PAYMENTACTION
='Sale',
378 PAYMENTREQUEST_0_AMT
=amt
,
379 PAYMENTREQUEST_0_CURRENCYCODE
='EUR',
382 response
= PrintOrder
.recordifyPPResp(response
)
383 # self._paypalLog.append(response)
386 security
.declareProtected(ModifyPortalContent
, 'ppPay')
387 def ppPay(self
, token
, payerid
):
388 # assure le paiement paypal en une passe :
389 # récupération des détails et validation de la transaction.
391 wtool
= getUtilityByInterfaceName('Products.CMFCore.interfaces.IWorkflowTool')
392 wfstate
= wtool
.getInfoFor(self
, 'review_state', 'order_workflow')
393 paid
= wfstate
== 'paid'
396 details
= self
.ppGetExpressCheckoutDetails(token
)
398 if payerid
!= details
['PAYERID'] :
401 if details
['ACK'] == 'Success' :
402 response
= self
.ppDoExpressCheckoutPayment(token
,
405 if response
['ACK'] == 'Success' and \
406 response
['PAYMENTINFO_0_ACK'] == 'Success' and \
407 response
['PAYMENTINFO_0_PAYMENTSTATUS'] == 'Completed' :
408 self
.paid
= (DateTime(), 'paypal')
409 wtool
= getUtilityByInterfaceName('Products.CMFCore.interfaces.IWorkflowTool')
410 wtool
.doActionFor( self
412 , wf_id
='order_workflow'
413 , comments
='Paiement par PayPal')
419 security
.declareProtected(ModifyPortalContent
, 'ppCancel')
420 def ppCancel(self
, token
) :
421 details
= self
.ppGetExpressCheckoutDetails(token
)
423 security
.declareProtected(ManagePortal
, 'getPPLog')
425 return self
._paypalLog
429 # security.declareProtected(View, 'getPaymentRequest')
430 # def getPaymentRequest(self) :
431 # config = _getCyberplusConfig()
432 # requester = CyberplusRequester(config)
433 # hereurl = self.absolute_url()
434 # amount = self.price + self.shippingFees
435 # amount = amount.getValues()['taxed']
436 # amount = amount * 100
437 # amount = str(int(round(amount, 0)))
438 # pptool = getToolByName(self, 'portal_photo_print')
439 # transaction_id = pptool.getNextTransactionId()
441 # userLanguages = getPreferredLanguages(self)
442 # for pref in userLanguages :
443 # lang = pref.split('-')[0]
444 # if lang in CYBERPLUS_LANGUAGES :
449 # options = { 'amount': amount
450 # ,'cancel_return_url' : '%s/paymentCancelHandler' % hereurl
451 # ,'normal_return_url' : '%s/paymentManualResponseHandler' % hereurl
452 # ,'automatic_response_url' :'%s/paymentAutoResponseHandler' % hereurl
453 # ,'transaction_id' : transaction_id
454 # ,'order_id' : self.getId()
457 # req = requester.generateRequest(options)
460 # def _decodeCyberplusResponse(self, form) :
461 # config = _getCyberplusConfig()
462 # responder = CyberplusResponder(config)
463 # response = responder.getResponse(form)
466 # def _compareWithAutoResponse(self, manu) :
468 # auto = self._paymentResponse
469 # autoKeys = auto.keys()
470 # if len(keys) != len(autoKeys) :
471 # console.warn('Manual has not the same keys.\nauto: %r\nmanual: %r' % \
472 # (sorted(autoKeys), sorted(keys)))
474 # for k, v in manu.items() :
475 # if not auto.has_key(k) :
476 # console.warn('%r field only found in manual response.' % k)
479 # console.warn('data mismatch for %r\nauto: %r\nmanual: %r' % (k, auto[k], v))
481 # def _checkOrderId(self, response) :
482 # expected = self.getId()
483 # assert expected == response['order_id'], \
484 # "Cyberplus response transaction_id doesn't match the order object:\n" \
486 # "found: %s" % (expected, response['transaction_id'])
488 # def _executeOrderWfTransition(self, response) :
489 # if CyberplusResponder.transactionAccepted(response) :
490 # wfaction = 'auto_accept_payment'
491 # elif CyberplusResponder.transactionRefused(response) :
492 # self.resetCopiesCounters()
493 # wfaction = 'auto_refuse_payment'
494 # elif CyberplusResponder.transactionCanceled(response) :
495 # wfaction = 'auto_cancel_order'
497 # # transaction failed
498 # wfaction = 'auto_transaction_failed'
500 # wtool = getToolByName(self, 'portal_workflow')
501 # wf = wtool.getWorkflowById('order_workflow')
502 # tdef = wf.transitions.get(wfaction)
503 # wf._changeStateOf(self, tdef)
504 # wtool._reindexWorkflowVariables(self)
506 # security.declarePublic('paymentAutoResponseHandler')
508 # def paymentAutoResponseHandler(self, REQUEST) :
510 # Handle cyberplus payment auto response.
512 # response = self._decodeCyberplusResponse(REQUEST.form)
513 # self._checkOrderId(response)
514 # self._paymentResponse.update(response)
515 # self._executeOrderWfTransition(response)
518 # def paymentManualResponseHandler(self, REQUEST) :
520 # Handle cyberplus payment manual response.
522 # response = self._decodeCyberplusResponse(REQUEST.form)
523 # self._checkOrderId(response)
525 # autoResponse = self._paymentResponse
526 # if not autoResponse :
527 # console.warn('Manual response handled before auto response at %s' % '/'.join(self.getPhysicalPath()))
528 # self._paymentResponse.update(response)
529 # self._executeOrderWfTransition(response)
531 # self._compareWithAutoResponse(response)
533 # url = '%s?%s' % (self.absolute_url(),
534 # make_query(portal_status_message=translate('Your payment is complete.', self).encode('utf-8'))
536 # return REQUEST.RESPONSE.redirect(url)
539 # def paymentCancelHandler(self, REQUEST) :
541 # Handle cyberplus cancel response.
542 # This handler can be invoqued in two cases:
543 # - the user cancel the payment form
544 # - the payment transaction has been refused
546 # response = self._decodeCyberplusResponse(REQUEST.form)
547 # self._checkOrderId(response)
549 # if self._paymentResponse :
550 # # normaly, it happens when the transaction is refused by cyberplus.
551 # self._compareWithAutoResponse(response)
554 # if CyberplusResponder.transactionRefused(response) :
555 # if not self._paymentResponse :
556 # console.warn('Manual response handled before auto response at %s' % '/'.join(self.getPhysicalPath()))
557 # self._paymentResponse.update(response)
558 # self._executeOrderWfTransition(response)
560 # msg = 'Your payment has been refused.'
563 # self._executeOrderWfTransition(response)
564 # msg = 'Your payment has been canceled. You will be able to pay later.'
566 # url = '%s?%s' % (self.absolute_url(),
567 # make_query(portal_status_message= \
568 # translate(msg, self).encode('utf-8'))
570 # return REQUEST.RESPONSE.redirect(url)
573 def getCustomerSummary(self
) :
575 return {'quantity':self
.quantity
,
579 InitializeClass(PrintOrder
)
580 PrintOrderFactory
= Factory(PrintOrder
)
583 class CopiesCounters(Persistent
, Implicit
) :
586 self
._mapping
= PersistentMapping()
588 def getBrowserId(self
):
589 sdm
= self
.session_data_manager
590 bim
= sdm
.getBrowserIdManager()
591 browserId
= bim
.getBrowserId(create
=1)
594 def _checkBrowserId(self
, browserId
) :
595 sdm
= self
.session_data_manager
596 sd
= sdm
.getSessionDataByKey(browserId
)
599 def __setitem__(self
, reference
, count
) :
600 if not self
._mapping
.has_key(reference
):
601 self
._mapping
[reference
] = PersistentMapping()
602 self
._mapping
[reference
]['pending'] = PersistentMapping()
603 self
._mapping
[reference
]['confirmed'] = 0
605 globalCount
= self
[reference
]
606 delta
= count
- globalCount
607 bid
= self
.getBrowserId()
608 if not self
._mapping
[reference
]['pending'].has_key(bid
) :
609 self
._mapping
[reference
]['pending'][bid
] = delta
611 self
._mapping
[reference
]['pending'][bid
] += delta
614 def __getitem__(self
, reference
) :
615 item
= self
._mapping
[reference
]
616 globalCount
= item
['confirmed']
618 for browserId
, count
in item
['pending'].items() :
619 if self
._checkBrowserId
(browserId
) :
622 del self
._mapping
[reference
]['pending'][browserId
]
626 def get(self
, reference
, default
=0) :
627 if self
._mapping
.has_key(reference
) :
628 return self
[reference
]
632 def getPendingCounter(self
, reference
) :
633 bid
= self
.getBrowserId()
634 if not self
._checkBrowserId
(bid
) :
635 console
.warn('BrowserId not found: %s' % bid
)
638 count
= self
._mapping
[reference
]['pending'].get(bid
, None)
640 console
.warn('No pending data found for browserId %s' % bid
)
645 def confirm(self
, reference
, quantity
) :
646 pending
= self
.getPendingCounter(reference
)
647 if pending
!= quantity
:
648 console
.warn('Pending quantity mismatch with the confirmed value: (%d, %d)' % (pending
, quantity
))
650 browserId
= self
.getBrowserId()
651 if self
._mapping
[reference
]['pending'].has_key(browserId
) :
652 del self
._mapping
[reference
]['pending'][browserId
]
653 self
._mapping
[reference
]['confirmed'] += quantity
655 def cancel(self
, reference
, quantity
) :
656 self
._mapping
[reference
]['confirmed'] -= quantity
659 return str(self
._mapping
)