815171b6080438ba1257790435331b188cf450b1
[photoprint.git] / order.py
1 # -*- coding: utf-8 -*-
2 #######################################################################################
3 # Plinn - http://plinn.org #
4 # Copyright (C) 2009-2013 Benoît PIN <benoit.pin@ensmp.fr> #
5 # #
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. #
10 # #
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. #
15 # #
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 #######################################################################################
20 """
21 Print order classes
22
23
24
25 """
26
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 xml.dom.minidom import Document
45 from tool import COPIES_COUNTERS
46 from App.config import getConfiguration
47 from paypal.interface import PayPalInterface
48 from logging import getLogger
49 console = getLogger('Products.photoprint.order')
50
51
52 def getPayPalConfig() :
53 zopeConf = getConfiguration()
54 try :
55 conf = zopeConf.product_config['photoprint']
56 except KeyError :
57 EnvironmentError("No photoprint configuration found in Zope environment.")
58
59 ppconf = {'API_ENVIRONMENT' : conf['paypal_api_environment'],
60 'API_USERNAME' : conf['paypal_username'],
61 'API_PASSWORD' : conf['paypal_password'],
62 'API_SIGNATURE' : conf['paypal_signature']}
63
64 return ppconf
65
66
67 class PrintOrderTemplate(SimpleItem) :
68 """
69 predefined print order
70 """
71 implements(IPrintOrderTemplate)
72
73 security = ClassSecurityInfo()
74
75 def __init__(self
76 , id
77 , title=''
78 , description=''
79 , productReference=''
80 , maxCopies=0
81 , price=0
82 , VATRate=0) :
83 self.id = id
84 self.title = title
85 self.description = description
86 self.productReference = productReference
87 self.maxCopies = maxCopies # 0 means unlimited
88 self.price = Price(price, VATRate)
89
90 security.declareProtected(ManagePrintOrderTemplate, 'edit')
91 def edit( self
92 , title=''
93 , description=''
94 , productReference=''
95 , maxCopies=0
96 , price=0
97 , VATRate=0 ) :
98 self.title = title
99 self.description = description
100 self.productReference = productReference
101 self.maxCopies = maxCopies
102 self.price = Price(price, VATRate)
103
104 security.declareProtected(ManagePrintOrderTemplate, 'formWidgetData')
105 def formWidgetData(self, REQUEST=None, RESPONSE=None):
106 """formWidgetData documentation
107 """
108 d = Document()
109 d.encoding = 'utf-8'
110 root = d.createElement('formdata')
111 d.appendChild(root)
112
113 def gua(name) :
114 return str(getattr(self, name, '')).decode('utf-8')
115
116 id = d.createElement('id')
117 id.appendChild(d.createTextNode(self.getId()))
118 root.appendChild(id)
119
120 title = d.createElement('title')
121 title.appendChild(d.createTextNode(gua('title')))
122 root.appendChild(title)
123
124 description = d.createElement('description')
125 description.appendChild(d.createTextNode(gua('description')))
126 root.appendChild(description)
127
128 productReference = d.createElement('productReference')
129 productReference.appendChild(d.createTextNode(gua('productReference')))
130 root.appendChild(productReference)
131
132 maxCopies = d.createElement('maxCopies')
133 maxCopies.appendChild(d.createTextNode(str(self.maxCopies)))
134 root.appendChild(maxCopies)
135
136 price = d.createElement('price')
137 price.appendChild(d.createTextNode(str(self.price.taxed)))
138 root.appendChild(price)
139
140 vatrate = d.createElement('VATRate')
141 vatrate.appendChild(d.createTextNode(str(self.price.vat)))
142 root.appendChild(vatrate)
143
144 if RESPONSE is not None :
145 RESPONSE.setHeader('content-type', 'text/xml; charset=utf-8')
146
147 manager = getToolByName(self, 'caching_policy_manager', None)
148 if manager is not None:
149 view_name = 'formWidgetData'
150 headers = manager.getHTTPCachingHeaders(
151 self, view_name, {}
152 )
153
154 for key, value in headers:
155 if key == 'ETag':
156 RESPONSE.setHeader(key, value, literal=1)
157 else:
158 RESPONSE.setHeader(key, value)
159 if headers:
160 RESPONSE.setHeader('X-Cache-Headers-Set-By',
161 'CachingPolicyManager: %s' %
162 '/'.join(manager.getPhysicalPath()))
163
164
165 return d.toxml('utf-8')
166
167
168 InitializeClass(PrintOrderTemplate)
169 PrintOrderTemplateFactory = Factory(PrintOrderTemplate)
170
171 class PrintOrder(PortalContent, DefaultDublinCoreImpl) :
172
173 implements(IPrintOrder)
174 security = ClassSecurityInfo()
175
176 def __init__( self, id) :
177 DefaultDublinCoreImpl.__init__(self)
178 self.id = id
179 self.items = []
180 self.quantity = 0
181 self.price = Price(0, 0)
182 # billing and shipping addresses
183 self.billing = PersistentMapping()
184 self.shipping = PersistentMapping()
185 self.shippingFees = Price(0,0)
186 self._paymentResponse = PersistentMapping()
187
188 @property
189 def amountWithFees(self) :
190 return self.price + self.shippingFees
191
192
193 security.declareProtected(ModifyPortalContent, 'editBilling')
194 def editBilling(self
195 , name
196 , address
197 , city
198 , zipcode
199 , country
200 , phone) :
201 self.billing['name'] = name
202 self.billing['address'] = address
203 self.billing['city'] = city
204 self.billing['zipcode'] = zipcode
205 self.billing['country'] = country
206 self.billing['phone'] = phone
207
208 security.declareProtected(ModifyPortalContent, 'editShipping')
209 def editShipping(self, name, address, city, zipcode, country) :
210 self.shipping['name'] = name
211 self.shipping['address'] = address
212 self.shipping['city'] = city
213 self.shipping['zipcode'] = zipcode
214 self.shipping['country'] = country
215
216 security.declarePrivate('loadCart')
217 def loadCart(self, cart):
218 pptool = getToolByName(self, 'portal_photo_print')
219 uidh = getToolByName(self, 'portal_uidhandler')
220 mtool = getToolByName(self, 'portal_membership')
221
222 items = []
223 for item in cart :
224 photo = uidh.getObject(item['cmf_uid'])
225 pOptions = pptool.getPrintingOptionsContainerFor(photo)
226 template = getattr(pOptions, item['printing_template'])
227
228 reference = template.productReference
229 quantity = item['quantity']
230 uPrice = template.price
231 self.quantity += quantity
232
233 d = {'cmf_uid' : item['cmf_uid']
234 ,'url' : photo.absolute_url()
235 ,'title' : template.title
236 ,'description' : template.description
237 ,'unit_price' : Price(uPrice._taxed, uPrice._rate)
238 ,'quantity' : quantity
239 ,'productReference' : reference
240 }
241 items.append(d)
242 self.price += uPrice * quantity
243 # confirm counters
244 if template.maxCopies :
245 counters = getattr(photo, COPIES_COUNTERS)
246 counters.confirm(reference, quantity)
247
248 self.items = tuple(items)
249
250 member = mtool.getAuthenticatedMember()
251 mg = lambda name : member.getProperty(name, '')
252 billing = {'name' : member.getMemberFullName(nameBefore=0)
253 ,'address' : mg('billing_address')
254 ,'city' : mg('billing_city')
255 ,'zipcode' : mg('billing_zipcode')
256 ,'country' : mg('country')
257 ,'phone' : mg('phone') }
258 self.editBilling(**billing)
259
260 sg = lambda name : cart._shippingInfo.get(name, '')
261 shipping = {'name' : sg('shipping_fullname')
262 ,'address' : sg('shipping_address')
263 ,'city' : sg('shipping_city')
264 ,'zipcode' : sg('shipping_zipcode')
265 ,'country' : sg('shipping_country')}
266 self.editShipping(**shipping)
267
268 self.shippingFees = pptool.getShippingFeesFor(shippable=self)
269
270 cart._confirmed = True
271 cart.pendingOrderPath = self.getPhysicalPath()
272
273 security.declareProtected(ManagePrintOrders, 'resetCopiesCounters')
274 def resetCopiesCounters(self) :
275 pptool = getToolByName(self, 'portal_photo_print')
276 uidh = getToolByName(self, 'portal_uidhandler')
277
278 for item in self.items :
279 photo = uidh.getObject(item['cmf_uid'])
280 counters = getattr(photo, COPIES_COUNTERS, None)
281 if counters :
282 counters.cancel(item['productReference'],
283 item['quantity'])
284
285
286 def _initPayPalInterface(self) :
287 config = getPayPalConfig()
288 config['API_AUTHENTICATION_MODE'] = '3TOKEN'
289 ppi = PayPalInterface(**config)
290 return ppi
291
292
293 @staticmethod
294 def recordifyPPResp(response) :
295 d = {}
296 d['zopeTime'] = DateTime()
297 for k, v in response.raw.iteritems() :
298 if len(v) == 1 :
299 d[k] = v[0]
300 else :
301 d[k] = v
302 return d
303
304 # paypal api
305 security.declareProtected(ModifyPortalContent, 'ppSetExpressCheckout')
306 def ppSetExpressCheckout(self) :
307 utool = getUtilityByInterfaceName('Products.CMFCore.interfaces.IURLTool')
308 mtool = getUtilityByInterfaceName('Products.CMFCore.interfaces.IMembershipTool')
309 portal_url = utool()
310 portal = utool.getPortalObject()
311 member = mtool.getAuthenticatedMember()
312
313 options = {#'PAYMENTREQUEST_0_AMT' : '99.55', # todo
314 'PAYMENTREQUEST_0_CURRENCYCODE' : 'EUR',
315 'PAYMENTREQUEST_0_PAYMENTACTION' : 'Sale',
316 'RETURNURL' : '%s/photoprint_order_confirm' % self.absolute_url(),
317 'CANCELURL' : '%s/photoprint_order_cancel' % self.absolute_url(),
318 # 'CALLBACK' : TODO
319 'ALLOWNOTE' : 0, # The buyer is unable to enter a note to the merchant.
320 'HDRIMG' : '%s/logo.gif' % portal_url,
321 'EMAIL' : member.getProperty('email'),
322 'SOLUTIONTYPE' : 'Sole', # Buyer does not need to create a PayPal account to check out. This is referred to as PayPal Account Optional.
323 'LANDINGPAGE' : 'Billing', # Non-PayPal account
324 'BRANDNAME' : portal.getProperty('title'),
325 'GIFTMESSAGEENABLE' : 0,
326 'GIFTRECEIPTENABLE' : 0,
327 'BUYEREMAILOPTINENABLE' : 0, # Do not enable buyer to provide email address.
328 'NOSHIPPING' : 1, # PayPal does not display shipping address fields whatsoever.
329 # 'PAYMENTREQUEST_0_NOTIFYURL' : TODO
330
331 'PAYMENTREQUEST_0_SHIPTONAME' : self.billing['name'],
332 'PAYMENTREQUEST_0_SHIPTOSTREET' : self.billing['address'],
333 'PAYMENTREQUEST_0_SHIPTOCITY' : self.billing['city'],
334 'PAYMENTREQUEST_0_SHIPTOZIP' : self.billing['zipcode'],
335 'PAYMENTREQUEST_0_SHIPTOPHONENUM' : self.billing['phone'],
336 }
337
338 if len(self.items) > 1 :
339 quantitySum = reduce(lambda a, b : a['quantity'] + b['quantity'], self.items)
340 else :
341 quantitySum = self.items[0]['quantity']
342 total = round(self.amountWithFees.getValues()['taxed'], 2)
343
344 options['L_PAYMENTREQUEST_0_NAME0'] = 'Commande realis photo ref. %s' % self.getId()
345 if quantitySum == 1 :
346 options['L_PAYMENTREQUEST_0_DESC0'] = "Commande d'un tirage photographique"
347 else :
348 options['L_PAYMENTREQUEST_0_DESC0'] = 'Commande de %d tirages photographiques' % quantitySum
349 options['L_PAYMENTREQUEST_0_AMT0'] = total
350 options['PAYMENTINFO_0_SHIPPINGAMT'] = round(self.shippingFees.getValues()['taxed'], 2)
351 # options['L_PAYMENTREQUEST_0_TAXAMT0'] = tax
352 # options['L_PAYMENTREQUEST_0_QTY%d' % n] = 1
353 options['PAYMENTREQUEST_0_AMT'] = total
354
355 ppi = self._initPayPalInterface()
356 response = ppi.set_express_checkout(**options)
357 response = PrintOrder.recordifyPPResp(response)
358 # self._paypalLog.append(response)
359 response['url'] = ppi.generate_express_checkout_redirect_url(response['TOKEN'])
360 console.info(options)
361 console.info(response)
362 return response
363
364 security.declarePrivate('ppGetExpressCheckoutDetails')
365 def ppGetExpressCheckoutDetails(self, token) :
366 ppi = self._initPayPalInterface()
367 response = ppi.get_express_checkout_details(TOKEN=token)
368 response = PrintOrder.recordifyPPResp(response)
369 # self._paypalLog.append(response)
370 return response
371
372 security.declarePrivate('ppDoExpressCheckoutPayment')
373 def ppDoExpressCheckoutPayment(self, token, payerid, amt) :
374 ppi = self._initPayPalInterface()
375 response = ppi.do_express_checkout_payment(PAYMENTREQUEST_0_PAYMENTACTION='Sale',
376 PAYMENTREQUEST_0_AMT=amt,
377 PAYMENTREQUEST_0_CURRENCYCODE='EUR',
378 TOKEN=token,
379 PAYERID=payerid)
380 response = PrintOrder.recordifyPPResp(response)
381 # self._paypalLog.append(response)
382 return response
383
384 security.declareProtected(ModifyPortalContent, 'ppPay')
385 def ppPay(self, token, payerid):
386 # assure le paiement paypal en une passe :
387 # récupération des détails et validation de la transaction.
388
389 wtool = getUtilityByInterfaceName('Products.CMFCore.interfaces.IWorkflowTool')
390 wfstate = wtool.getInfoFor(self, 'review_state', 'order_workflow')
391 paid = wfstate == 'paid'
392
393 if not paid :
394 details = self.ppGetExpressCheckoutDetails(token)
395
396 if payerid != details['PAYERID'] :
397 return False
398
399 if details['ACK'] == 'Success' :
400 response = self.ppDoExpressCheckoutPayment(token,
401 payerid,
402 details['AMT'])
403 if response['ACK'] == 'Success' and \
404 response['PAYMENTINFO_0_ACK'] == 'Success' and \
405 response['PAYMENTINFO_0_PAYMENTSTATUS'] == 'Completed' :
406 self.paid = (DateTime(), 'paypal')
407 wtool = getUtilityByInterfaceName('Products.CMFCore.interfaces.IWorkflowTool')
408 wtool.doActionFor( self
409 , 'paypal_pay'
410 , wf_id='order_workflow'
411 , comments='Paiement par PayPal')
412 return True
413 return False
414 else :
415 return True
416
417 security.declareProtected(ModifyPortalContent, 'ppCancel')
418 def ppCancel(self, token) :
419 details = self.ppGetExpressCheckoutDetails(token)
420
421 security.declareProtected(ManagePortal, 'getPPLog')
422 def getPPLog(self) :
423 return self._paypalLog
424
425 def getCustomerSummary(self) :
426 ' '
427 return {'quantity':self.quantity,
428 'price':self.price}
429
430
431 InitializeClass(PrintOrder)
432 PrintOrderFactory = Factory(PrintOrder)
433
434
435 class CopiesCounters(Persistent, Implicit) :
436
437 def __init__(self):
438 self._mapping = PersistentMapping()
439
440 def getBrowserId(self):
441 sdm = self.session_data_manager
442 bim = sdm.getBrowserIdManager()
443 browserId = bim.getBrowserId(create=1)
444 return browserId
445
446 def _checkBrowserId(self, browserId) :
447 sdm = self.session_data_manager
448 sd = sdm.getSessionDataByKey(browserId)
449 return not not sd
450
451 def __setitem__(self, reference, count) :
452 if not self._mapping.has_key(reference):
453 self._mapping[reference] = PersistentMapping()
454 self._mapping[reference]['pending'] = PersistentMapping()
455 self._mapping[reference]['confirmed'] = 0
456
457 globalCount = self[reference]
458 delta = count - globalCount
459 bid = self.getBrowserId()
460 if not self._mapping[reference]['pending'].has_key(bid) :
461 self._mapping[reference]['pending'][bid] = delta
462 else :
463 self._mapping[reference]['pending'][bid] += delta
464
465
466 def __getitem__(self, reference) :
467 item = self._mapping[reference]
468 globalCount = item['confirmed']
469
470 for browserId, count in item['pending'].items() :
471 if self._checkBrowserId(browserId) :
472 globalCount += count
473 else :
474 del self._mapping[reference]['pending'][browserId]
475
476 return globalCount
477
478 def get(self, reference, default=0) :
479 if self._mapping.has_key(reference) :
480 return self[reference]
481 else :
482 return default
483
484 def getPendingCounter(self, reference) :
485 bid = self.getBrowserId()
486 if not self._checkBrowserId(bid) :
487 console.warn('BrowserId not found: %s' % bid)
488 return 0
489
490 count = self._mapping[reference]['pending'].get(bid, None)
491 if count is None :
492 console.warn('No pending data found for browserId %s' % bid)
493 return 0
494 else :
495 return count
496
497 def confirm(self, reference, quantity) :
498 pending = self.getPendingCounter(reference)
499 if pending != quantity :
500 console.warn('Pending quantity mismatch with the confirmed value: (%d, %d)' % (pending, quantity))
501
502 browserId = self.getBrowserId()
503 if self._mapping[reference]['pending'].has_key(browserId) :
504 del self._mapping[reference]['pending'][browserId]
505 self._mapping[reference]['confirmed'] += quantity
506
507 def cancel(self, reference, quantity) :
508 self._mapping[reference]['confirmed'] -= quantity
509
510 def __str__(self):
511 return str(self._mapping)