eggification
[photoprint.git] / Products / photoprint / 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.discount = 0 # discount ratio in percent
187 self.price = Price(0, 0)
188 # billing and shipping addresses
189 self.billing = PersistentMapping()
190 self.shipping = PersistentMapping()
191 self.shippingFees = Price(0,0)
192 self._paypalLog = PersistentList()
193
194 @property
195 def amountWithFees(self) :
196 coeff = (100 - self.discount) / 100.
197 return self.price * coeff + self.shippingFees
198
199
200 security.declareProtected(ModifyPortalContent, 'editBilling')
201 def editBilling(self
202 , name
203 , address
204 , city
205 , zipcode
206 , country
207 , phone) :
208 self.billing['name'] = name
209 self.billing['address'] = address
210 self.billing['city'] = city
211 self.billing['zipcode'] = zipcode
212 self.billing['country'] = country
213 self.billing['phone'] = phone
214
215 security.declareProtected(ModifyPortalContent, 'editShipping')
216 def editShipping(self, name, address, city, zipcode, country) :
217 self.shipping['name'] = name
218 self.shipping['address'] = address
219 self.shipping['city'] = city
220 self.shipping['zipcode'] = zipcode
221 self.shipping['country'] = country
222
223 security.declarePrivate('loadCart')
224 def loadCart(self, cart):
225 pptool = getToolByName(self, 'portal_photo_print')
226 uidh = getToolByName(self, 'portal_uidhandler')
227 mtool = getToolByName(self, 'portal_membership')
228 utool = getToolByName(self, 'portal_url')
229
230 items = []
231 for item in cart :
232 photo = uidh.getObject(item['cmf_uid'])
233 pOptions = pptool.getPrintingOptionsContainerFor(photo)
234 template = getattr(pOptions, item['printing_template'])
235
236 reference = template.productReference
237 quantity = item['quantity']
238 uPrice = template.price
239 self.quantity += quantity
240
241 d = {'cmf_uid' : item['cmf_uid']
242 ,'url' : photo.absolute_url()
243 ,'title' : template.title
244 ,'description' : template.description
245 ,'unit_price' : Price(uPrice._taxed, uPrice._rate)
246 ,'quantity' : quantity
247 ,'productReference' : reference
248 }
249 items.append(d)
250 self.price += uPrice * quantity
251 # confirm counters
252 if template.maxCopies :
253 counters = getattr(photo, COPIES_COUNTERS)
254 counters.confirm(reference, quantity)
255
256 self.items = tuple(items)
257 discount_script = getattr(utool.getPortalObject(), 'photoprint_discount', None)
258 if discount_script :
259 self.discount = discount_script(self.price, self.quantity)
260
261 member = mtool.getAuthenticatedMember()
262 mg = lambda name : member.getProperty(name, '')
263 billing = {'name' : member.getMemberFullName(nameBefore=0)
264 ,'address' : mg('billing_address')
265 ,'city' : mg('billing_city')
266 ,'zipcode' : mg('billing_zipcode')
267 ,'country' : mg('country')
268 ,'phone' : mg('phone') }
269 self.editBilling(**billing)
270
271 sg = lambda name : cart._shippingInfo.get(name, '')
272 shipping = {'name' : sg('shipping_fullname')
273 ,'address' : sg('shipping_address')
274 ,'city' : sg('shipping_city')
275 ,'zipcode' : sg('shipping_zipcode')
276 ,'country' : sg('shipping_country')}
277 self.editShipping(**shipping)
278
279 self.shippingFees = pptool.getShippingFeesFor(shippable=self)
280
281 cart._confirmed = True
282 cart.pendingOrderPath = self.getPhysicalPath()
283
284 security.declareProtected(ManagePrintOrders, 'resetCopiesCounters')
285 def resetCopiesCounters(self) :
286 pptool = getToolByName(self, 'portal_photo_print')
287 uidh = getToolByName(self, 'portal_uidhandler')
288
289 for item in self.items :
290 photo = uidh.getObject(item['cmf_uid'])
291 counters = getattr(photo, COPIES_COUNTERS, None)
292 if counters :
293 counters.cancel(item['productReference'],
294 item['quantity'])
295
296
297 def _initPayPalInterface(self) :
298 config = getPayPalConfig()
299 config['API_AUTHENTICATION_MODE'] = '3TOKEN'
300 ppi = PayPalInterface(**config)
301 return ppi
302
303
304 @staticmethod
305 def recordifyPPResp(response) :
306 d = {}
307 d['zopeTime'] = DateTime()
308 for k, v in response.raw.iteritems() :
309 if len(v) == 1 :
310 d[k] = v[0]
311 else :
312 d[k] = v
313 return d
314
315 # paypal api
316 security.declareProtected(ModifyPortalContent, 'ppSetExpressCheckout')
317 def ppSetExpressCheckout(self) :
318 utool = getUtilityByInterfaceName('Products.CMFCore.interfaces.IURLTool')
319 mtool = getUtilityByInterfaceName('Products.CMFCore.interfaces.IMembershipTool')
320 portal_url = utool()
321 portal = utool.getPortalObject()
322 member = mtool.getAuthenticatedMember()
323
324 options = {'PAYMENTREQUEST_0_CURRENCYCODE' : 'EUR',
325 'PAYMENTREQUEST_0_PAYMENTACTION' : 'Sale',
326 'RETURNURL' : '%s/photoprint_order_confirm' % self.absolute_url(),
327 'CANCELURL' : '%s/photoprint_order_cancel' % self.absolute_url(),
328 'ALLOWNOTE' : 0, # The buyer is unable to enter a note to the merchant.
329 'HDRIMG' : '%s/logo.gif' % portal_url,
330 'EMAIL' : member.getProperty('email'),
331 'SOLUTIONTYPE' : 'Sole', # Buyer does not need to create a PayPal account to check out. This is referred to as PayPal Account Optional.
332 'LANDINGPAGE' : 'Billing', # Non-PayPal account
333 'BRANDNAME' : portal.getProperty('title'),
334 'GIFTMESSAGEENABLE' : 0,
335 'GIFTRECEIPTENABLE' : 0,
336 'BUYEREMAILOPTINENABLE' : 0, # Do not enable buyer to provide email address.
337 'NOSHIPPING' : 1, # PayPal does not display shipping address fields whatsoever.
338 'PAYMENTREQUEST_0_SHIPTONAME' : self.billing['name'],
339 'PAYMENTREQUEST_0_SHIPTOSTREET' : self.billing['address'],
340 'PAYMENTREQUEST_0_SHIPTOCITY' : self.billing['city'],
341 'PAYMENTREQUEST_0_SHIPTOZIP' : self.billing['zipcode'],
342 'PAYMENTREQUEST_0_SHIPTOPHONENUM' : self.billing['phone'],
343 }
344
345 if len(self.items) > 1 :
346 quantitySum = reduce(lambda a, b : a + b, [item['quantity'] for item in self.items])
347 else :
348 quantitySum = self.items[0]['quantity']
349 total = round(self.amountWithFees.getValues()['taxed'], 2)
350
351 options['L_PAYMENTREQUEST_0_NAME0'] = 'Commande photo ref. %s' % self.getId()
352 if quantitySum == 1 :
353 options['L_PAYMENTREQUEST_0_DESC0'] = "Commande d'un tirage photographique"
354 else :
355 options['L_PAYMENTREQUEST_0_DESC0'] = 'Commande de %d tirages photographiques' % quantitySum
356 options['L_PAYMENTREQUEST_0_AMT0'] = total
357 options['PAYMENTINFO_0_SHIPPINGAMT'] = round(self.shippingFees.getValues()['taxed'], 2)
358 options['PAYMENTREQUEST_0_AMT'] = total
359
360 ppi = self._initPayPalInterface()
361 response = ppi.set_express_checkout(**options)
362 response = PrintOrder.recordifyPPResp(response)
363 self._paypalLog.append(response)
364 response['url'] = ppi.generate_express_checkout_redirect_url(response['TOKEN'])
365 console.info(options)
366 console.info(response)
367 return response
368
369 security.declarePrivate('ppGetExpressCheckoutDetails')
370 def ppGetExpressCheckoutDetails(self, token) :
371 ppi = self._initPayPalInterface()
372 response = ppi.get_express_checkout_details(TOKEN=token)
373 response = PrintOrder.recordifyPPResp(response)
374 self._paypalLog.append(response)
375 return response
376
377 security.declarePrivate('ppDoExpressCheckoutPayment')
378 def ppDoExpressCheckoutPayment(self, token, payerid, amt) :
379 ppi = self._initPayPalInterface()
380 response = ppi.do_express_checkout_payment(PAYMENTREQUEST_0_PAYMENTACTION='Sale',
381 PAYMENTREQUEST_0_AMT=amt,
382 PAYMENTREQUEST_0_CURRENCYCODE='EUR',
383 TOKEN=token,
384 PAYERID=payerid)
385 response = PrintOrder.recordifyPPResp(response)
386 self._paypalLog.append(response)
387 return response
388
389 security.declareProtected(ModifyPortalContent, 'ppPay')
390 def ppPay(self, token, payerid):
391 # assure le paiement paypal en une passe :
392 # récupération des détails et validation de la transaction.
393
394 wtool = getUtilityByInterfaceName('Products.CMFCore.interfaces.IWorkflowTool')
395 wfstate = wtool.getInfoFor(self, 'review_state', 'order_workflow')
396 paid = wfstate == 'paid'
397
398 if not paid :
399 details = self.ppGetExpressCheckoutDetails(token)
400
401 if payerid != details['PAYERID'] :
402 return False
403
404 if details['ACK'] == 'Success' :
405 response = self.ppDoExpressCheckoutPayment(token,
406 payerid,
407 details['AMT'])
408 if response['ACK'] == 'Success' and \
409 response['PAYMENTINFO_0_ACK'] == 'Success' and \
410 response['PAYMENTINFO_0_PAYMENTSTATUS'] == 'Completed' :
411 self.paid = (DateTime(), 'paypal')
412 wtool = getUtilityByInterfaceName('Products.CMFCore.interfaces.IWorkflowTool')
413 wtool.doActionFor( self
414 , 'paypal_pay'
415 , wf_id='order_workflow'
416 , comments='Paiement par PayPal')
417 return True
418 return False
419 else :
420 return True
421
422 security.declareProtected(ModifyPortalContent, 'ppCancel')
423 def ppCancel(self, token) :
424 details = self.ppGetExpressCheckoutDetails(token)
425
426 security.declareProtected(ManagePortal, 'getPPLog')
427 def getPPLog(self) :
428 return self._paypalLog
429
430 def getCustomerSummary(self) :
431 ' '
432 return {'quantity':self.quantity,
433 'price':self.price}
434
435
436 InitializeClass(PrintOrder)
437 PrintOrderFactory = Factory(PrintOrder)
438
439
440 class CopiesCounters(Persistent, Implicit) :
441
442 def __init__(self):
443 self._mapping = PersistentMapping()
444
445 def getBrowserId(self):
446 sdm = self.session_data_manager
447 bim = sdm.getBrowserIdManager()
448 browserId = bim.getBrowserId(create=1)
449 return browserId
450
451 def _checkBrowserId(self, browserId) :
452 sdm = self.session_data_manager
453 sd = sdm.getSessionDataByKey(browserId)
454 return not not sd
455
456 def __setitem__(self, reference, count) :
457 if not self._mapping.has_key(reference):
458 self._mapping[reference] = PersistentMapping()
459 self._mapping[reference]['pending'] = PersistentMapping()
460 self._mapping[reference]['confirmed'] = 0
461
462 globalCount = self[reference]
463 delta = count - globalCount
464 bid = self.getBrowserId()
465 if not self._mapping[reference]['pending'].has_key(bid) :
466 self._mapping[reference]['pending'][bid] = delta
467 else :
468 self._mapping[reference]['pending'][bid] += delta
469
470
471 def __getitem__(self, reference) :
472 item = self._mapping[reference]
473 globalCount = item['confirmed']
474
475 for browserId, count in item['pending'].items() :
476 if self._checkBrowserId(browserId) :
477 globalCount += count
478 else :
479 del self._mapping[reference]['pending'][browserId]
480
481 return globalCount
482
483 def get(self, reference, default=0) :
484 if self._mapping.has_key(reference) :
485 return self[reference]
486 else :
487 return default
488
489 def getPendingCounter(self, reference) :
490 bid = self.getBrowserId()
491 if not self._checkBrowserId(bid) :
492 console.warn('BrowserId not found: %s' % bid)
493 return 0
494
495 count = self._mapping[reference]['pending'].get(bid, None)
496 if count is None :
497 console.warn('No pending data found for browserId %s' % bid)
498 return 0
499 else :
500 return count
501
502 def confirm(self, reference, quantity) :
503 pending = self.getPendingCounter(reference)
504 if pending != quantity :
505 console.warn('Pending quantity mismatch with the confirmed value: (%d, %d)' % (pending, quantity))
506
507 browserId = self.getBrowserId()
508 if self._mapping[reference]['pending'].has_key(browserId) :
509 del self._mapping[reference]['pending'][browserId]
510 self._mapping[reference]['confirmed'] += quantity
511
512 def cancel(self, reference, quantity) :
513 self._mapping[reference]['confirmed'] -= quantity
514
515 def __str__(self):
516 return str(self._mapping)