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