combo/combo/apps/lingo/views.py

651 lines
26 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
# lingo - basket and payment system
# Copyright (C) 2015 Entr'ouvert
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU Affero General Public License as published
# by the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from decimal import Decimal, ROUND_HALF_UP
import json
import logging
import requests
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.core.urlresolvers import reverse
from django.http import HttpResponse, HttpResponseRedirect, HttpResponseBadRequest
from django.http import HttpResponseForbidden, Http404
from django.template.response import TemplateResponse
from django.utils import timezone
from django.utils.encoding import force_text
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import View, DetailView, ListView, TemplateView
from django.conf import settings
from django.contrib import messages
from django.utils.translation import ugettext_lazy as _
from django.db.transaction import atomic
from django.utils.encoding import smart_text
2015-03-05 17:02:52 +01:00
import eopayment
from combo.utils import check_request_signature, aes_hex_decrypt, DecryptionError
if 'mellon' in settings.INSTALLED_APPS:
from mellon.models import UserSAMLIdentifier
else:
UserSAMLIdentifier = None
from .models import (Regie, BasketItem, Transaction, TransactionOperation,
LingoBasketCell, SelfDeclaredInvoicePayment)
def get_eopayment_object(request, regie):
options = regie.service_options
options.update({
'automatic_return_url': request.build_absolute_uri(
reverse('lingo-callback', kwargs={'regie_pk': regie.id})),
'normal_return_url': request.build_absolute_uri(
reverse('lingo-return', kwargs={'regie_pk': regie.id})),
})
return eopayment.Payment(regie.service, options)
def get_basket_url():
return LingoBasketCell.objects.all()[0].page.get_online_url()
def lingo_check_request_signature(request):
keys = []
if getattr(settings, 'LINGO_API_SIGN_KEY', None):
keys = [settings.LINGO_API_SIGN_KEY]
return check_request_signature(request, keys=keys)
class RegiesApiView(ListView):
model = Regie
def get(self, request, *args, **kwargs):
response = HttpResponse(content_type='application/json')
data = {'data': [x.as_api_dict() for x in self.get_queryset()]}
json_str = json.dumps(data)
if 'jsonpCallback' in request.GET:
json_str = '%s(%s);' % (request.GET['jsonpCallback'], json_str)
response.write(json_str)
return response
class AddBasketItemApiView(View):
http_method_names = ['post', 'options']
@csrf_exempt
def dispatch(self, *args, **kwargs):
return super(AddBasketItemApiView, self).dispatch(*args, **kwargs)
def get_amount(self, amount):
if isinstance(amount, list):
d = Decimal(sum([Decimal(a) for a in amount]))
else:
d = Decimal(amount)
return d.quantize(Decimal('0.01'), ROUND_HALF_UP)
def post(self, request, *args, **kwargs):
if not lingo_check_request_signature(request):
return HttpResponseForbidden()
request_body = json.loads(self.request.body)
extra = request_body.get('extra', {})
if not 'amount' in request.GET and not 'amount' in request_body and \
not 'amount' in extra:
raise Exception('missing amount parameter')
item = BasketItem(amount=0)
item.amount = self.get_amount(request.GET.getlist('amount'))
if request_body.get('amount'):
item.amount += self.get_amount(request_body['amount'])
if extra.get('amount'):
item.amount += self.get_amount(extra['amount'])
if 'extra' in request_body:
item.request_data = request_body.get('extra')
else:
item.request_data = request_body
try:
if request.GET.get('NameId'):
if UserSAMLIdentifier is None:
raise Exception('missing mellon?')
try:
user = UserSAMLIdentifier.objects.get(name_id=request.GET.get('NameId')).user
except UserSAMLIdentifier.DoesNotExist:
raise Exception('unknown name id')
elif request.GET.get('email'):
user = User.objects.get(email=request.GET.get('email'))
else:
raise Exception('no user specified')
except User.DoesNotExist:
raise Exception('unknown user')
item.user = user
if request.GET.get('regie_id'):
try:
item.regie = Regie.objects.get(slug=request.GET.get('regie_id'))
except Regie.DoesNotExist:
try:
item.regie = Regie.objects.get(id=int(request.GET.get('regie_id')))
except (ValueError, Regie.DoesNotExist):
return HttpResponseBadRequest('Unknown regie')
else:
try:
item.regie = Regie.objects.get(is_default=True)
except Regie.DoesNotExist:
# if there's no default regie, use the first one we get from
# the database...
item.regie = Regie.objects.all()[0]
if request.GET.get('cancellable') == 'no':
item.user_cancellable = False
item.subject = request_body.get('display_name')
item.source_url = request_body.get('url') or ''
item.save()
item.regie.compute_extra_fees(user=item.user)
response = HttpResponse(content_type='application/json')
response.write(json.dumps({'result': 'success', 'id': str(item.id)}))
return response
class RemoveBasketItemApiView(View):
http_method_names = ['post', 'options']
@csrf_exempt
def dispatch(self, *args, **kwargs):
return super(RemoveBasketItemApiView, self).dispatch(*args, **kwargs)
def post(self, request, *args, **kwargs):
if not lingo_check_request_signature(request):
return HttpResponseForbidden()
request_body = json.loads(self.request.body)
if not 'basket_item_id' in request_body:
raise Exception('missing basket_item_id parameter')
try:
if request.GET.get('NameId'):
if UserSAMLIdentifier is None:
raise Exception('missing mellon?')
try:
user = UserSAMLIdentifier.objects.get(name_id=request.GET.get('NameId')).user
except UserSAMLIdentifier.DoesNotExist:
raise Exception('unknown name id')
elif request.GET.get('email'):
user = User.objects.get(email=request.GET.get('email'))
else:
raise Exception('no user specified')
except User.DoesNotExist:
raise Exception('unknown user')
item = BasketItem.objects.get(id=request_body.get('basket_item_id'),
user=user, cancellation_date__isnull=True)
notify_origin = bool(request_body.get('notify', 'false') == 'true')
item.notify_cancellation(notify_origin=notify_origin)
response = HttpResponse(content_type='application/json')
response.write(json.dumps({'result': 'success'}))
return response
2015-03-05 17:02:52 +01:00
class ValidateTransactionApiView(View):
http_method_names = ['post', 'options']
@csrf_exempt
def dispatch(self, *args, **kwargs):
return super(ValidateTransactionApiView, self).dispatch(*args, **kwargs)
def post(self, request, *args, **kwargs):
if not lingo_check_request_signature(request):
return HttpResponseForbidden()
logger = logging.getLogger(__name__)
try:
transaction = Transaction.objects.get(id=request.GET['transaction_id'])
except Transaction.DoesNotExist:
logger.warning(u'received validate request for unknown transaction %s',
request.GET['transaction_id'])
raise Http404
payment = get_eopayment_object(request, transaction.regie)
amount = request.GET['amount']
logger.info(u'validating amount %s for transaction %s', amount, smart_text(transaction.id))
try:
result = payment.backend.validate(Decimal(amount), transaction.bank_data)
except eopayment.ResponseError as e:
logger.error(u'failed in validation operation: %s', e)
response = HttpResponse(content_type='application/json')
response.write(json.dumps({'err': 1, 'e': unicode(e)}))
return response
logger.info(u'bank validation result: %r', result)
operation = TransactionOperation(transaction=transaction,
kind='validation', amount=Decimal(amount), bank_result=result)
operation.save()
response = HttpResponse(content_type='application/json')
response.write(json.dumps({'err': 0, 'extra': result}))
return response
class CancelTransactionApiView(View):
http_method_names = ['post', 'options']
@csrf_exempt
def dispatch(self, *args, **kwargs):
return super(CancelTransactionApiView, self).dispatch(*args, **kwargs)
def post(self, request, *args, **kwargs):
if not lingo_check_request_signature(request):
return HttpResponseForbidden()
logger = logging.getLogger(__name__)
try:
transaction = Transaction.objects.get(id=request.GET['transaction_id'])
except Transaction.DoesNotExist:
logger.warning(u'received validate request for unknown transaction %s',
request.GET['transaction_id'])
raise Http404
payment = get_eopayment_object(request, transaction.regie)
amount = request.GET['amount']
logger.info(u'cancelling amount %s for transaction %s', amount, smart_text(transaction.id))
try:
result = payment.backend.cancel(Decimal(amount), transaction.bank_data)
except eopayment.ResponseError as e:
logger.error(u'failed in cancel operation: %s', e)
response = HttpResponse(content_type='application/json')
response.write(json.dumps({'err': 1, 'e': unicode(e)}))
return response
logger.info(u'bank cancellation result: %r', result)
operation = TransactionOperation(transaction=transaction,
kind='cancellation', amount=Decimal(amount), bank_result=result)
operation.save()
response = HttpResponse(content_type='application/json')
response.write(json.dumps({'err': 0, 'extra': result}))
return response
2015-03-05 17:02:52 +01:00
class PayView(View):
@atomic
2015-03-05 17:02:52 +01:00
def post(self, request, *args, **kwargs):
regie_id = request.POST.get('regie')
next_url = request.POST.get('next_url') or '/'
user = request.user if request.user.is_authenticated() else None
remote_items = []
items = []
if regie_id and Regie.objects.get(pk=regie_id).is_remote():
regie = Regie.objects.get(pk=regie_id)
# get all items data from regie webservice
for item_id in request.POST.getlist('item'):
remote_items.append(regie.get_invoice(user, item_id))
else:
if user is None:
messages.error(request, _(u'Payment requires to be logged in.'))
return HttpResponseRedirect(next_url)
if not regie_id:
# take all items but check they're from the same regie
items = BasketItem.get_items_to_be_paid(user=user)
regie_id = items[0].regie_id
for item in items:
if item.regie_id != regie_id:
messages.error(request, _(u'Invalid grouping for basket items.'))
return HttpResponseRedirect(next_url)
regie = Regie.objects.get(id=regie_id)
regie.compute_extra_fees(user=user)
items = BasketItem.get_items_to_be_paid(user=user).filter(regie=regie)
2015-03-05 17:02:52 +01:00
transaction = Transaction()
if user:
transaction.user = user
email = user.email
firstname = user.first_name
lastname = user.last_name
else:
transaction.user = None
if not request.POST.get('email'):
messages.warning(request, _(u'You must give an email address.'))
return HttpResponseRedirect(request.POST.get('item_url'))
email = request.POST.get('email')
firstname = ''
lastname = ''
2015-03-05 17:02:52 +01:00
transaction.save()
transaction.regie = regie
2015-03-05 17:02:52 +01:00
transaction.items = items
transaction.remote_items = ','.join([x.id for x in remote_items])
transaction.status = 0
2015-03-05 17:02:52 +01:00
if remote_items:
total_amount = sum([x.amount for x in remote_items])
else:
total_amount = sum([x.amount for x in items])
transaction.amount = total_amount
transaction.save()
2015-03-05 17:02:52 +01:00
if total_amount < regie.payment_min_amount:
messages.warning(request, _(u'Minimal payment amount is %s €.') % regie.payment_min_amount)
return HttpResponseRedirect(next_url)
payment = get_eopayment_object(request, regie)
(order_id, kind, data) = payment.request(total_amount, email=email,
first_name=firstname,
last_name=lastname)
logger = logging.getLogger(__name__)
logger.info(u'emitted payment request with id %s', smart_text(order_id), extra={
'eopayment_order_id': smart_text(order_id), 'eopayment_data': repr(data)})
2015-03-05 17:02:52 +01:00
transaction.order_id = order_id
transaction.save()
if next_url:
# store the next url in session in order to be able to redirect to
# it if payment is canceled
request.session.setdefault('lingo_next_url',
{})[transaction.order_id] = request.build_absolute_uri(next_url)
2015-03-05 17:02:52 +01:00
# XXX: mark basket items as being processed (?)
if kind == eopayment.URL:
return HttpResponseRedirect(data)
elif kind == eopayment.FORM:
return TemplateResponse(request, 'lingo/payment_form.html', {'form': data})
2015-03-05 17:02:52 +01:00
raise NotImplementedError()
class CallbackView(View):
def handle_callback(self, request, backend_response, **kwargs):
regie = Regie.objects.get(id=kwargs.get('regie_pk'))
payment = get_eopayment_object(request, regie)
logger = logging.getLogger(__name__)
try:
payment_response = payment.response(backend_response)
except eopayment.ResponseError as e:
logger.error(u'failed to process payment response: %s', e,
extra={'eopayment_raw_response': repr(backend_response)})
return HttpResponseBadRequest()
extra_info = {
'eopayment_order_id': smart_text(payment_response.order_id),
'eopayment_response': repr(payment_response),
}
for k, v in payment_response.bank_data.iteritems():
extra_info['eopayment_bank_data_' + k] = smart_text(v)
if not payment_response.signed and not payment_response.result == eopayment.CANCELLED:
# we accept unsigned cancellation requests as some platforms do
# that :/
logger.warning(u'received unsigned payment response with id %s',
smart_text(payment_response.order_id), extra=extra_info)
return HttpResponseBadRequest('Unsigned payment response')
try:
transaction = Transaction.objects.get(order_id=payment_response.order_id)
except Transaction.DoesNotExist:
logger.warning(u'received unknown payment response with id %s',
smart_text(payment_response.order_id), extra=extra_info)
raise Http404
else:
extra_info['lingo_transaction_id'] = transaction.pk
if transaction.user:
# let hobo logger filter handle the extraction of user's infos
extra_info['user'] = transaction.user
logger.info(u'received known payment response with id %s',
smart_text(payment_response.order_id), extra=extra_info)
if transaction.status and transaction.status != payment_response.result:
logger.warning(u'received payment notification on existing transaction '
'(status: %s, new status: %s)' % (
transaction.status, payment_response.result))
transaction.status = payment_response.result
transaction.bank_transaction_id = payment_response.transaction_id
transaction.bank_data = payment_response.bank_data
transaction.end_date = timezone.now()
transaction.save()
# check if transaction belongs to right regie
if not transaction.regie == regie:
return HttpResponseBadRequest('Invalid payment regie')
if payment_response.result == eopayment.WAITING:
# mark basket items as waiting for payment confirmation
transaction.items.all().update(waiting_date=timezone.now())
return HttpResponse()
if payment_response.result == eopayment.CANCELLED:
# mark basket items as no longer waiting so the user can restart a
# payment.
transaction.items.all().update(waiting_date=None)
return HttpResponse()
if payment_response.result not in (eopayment.PAID, eopayment.ACCEPTED):
return HttpResponse()
transaction.items.update(payment_date=transaction.end_date)
for item in transaction.items.all():
try:
item.notify_payment()
except:
# ignore errors, it will be retried later on if it fails
logger.exception('error in sync notification for basket item %s', item.id)
regie.compute_extra_fees(user=transaction.user)
if transaction.remote_items:
transaction.first_notify_remote_items_of_payments()
return HttpResponse()
def get(self, request, *args, **kwargs):
return self.handle_callback(request, request.environ['QUERY_STRING'], **kwargs)
def post(self, request, *args, **kwargs):
return self.handle_callback(request, request.body, **kwargs)
@csrf_exempt
def dispatch(self, *args, **kwargs):
return super(CallbackView, self).dispatch(*args, **kwargs)
class ReturnView(View):
@csrf_exempt
def dispatch(self, *args, **kwargs):
return super(ReturnView, self).dispatch(*args, **kwargs)
def get(self, request, *args, **kwargs):
return self.handle_return(request, request.environ['QUERY_STRING'], **kwargs)
def post(self, request, *args, **kwargs):
return self.handle_return(request, request.body, **kwargs)
def handle_return(self, request, backend_response, **kwargs):
regie = Regie.objects.get(id=kwargs.get('regie_pk'))
payment = get_eopayment_object(request, regie)
try:
payment_response = payment.response(backend_response)
except eopayment.ResponseError as e:
# if eopayment can't get response from query string redirect to
# homepage
logging.error('failed to process payment response (%r)', e)
messages.error(request, _('We are sorry but the payment service '
'failed to provide a correct answer.'))
return HttpResponseRedirect(get_basket_url())
if payment_response.result in (eopayment.PAID, eopayment.ACCEPTED):
messages.info(request, regie.get_text_on_success())
transaction = Transaction.objects.get(order_id=payment_response.order_id)
if request.session.get('lingo_next_url'):
redirect_url = request.session['lingo_next_url'].get(transaction.order_id)
if redirect_url:
return HttpResponseRedirect(redirect_url)
# return to basket page if there are still items to pay
if request.user.is_authenticated():
remaining_basket_items = BasketItem.get_items_to_be_paid(
user=self.request.user).count()
if remaining_basket_items:
return HttpResponseRedirect(get_basket_url())
return HttpResponseRedirect('/')
2015-09-03 16:03:44 +02:00
class ItemDownloadView(View):
http_method_names = [u'get']
def get(self, request, *args, **kwargs):
try:
regie = Regie.objects.get(pk=kwargs['regie_id'])
except Regie.DoesNotExist:
raise Http404()
try:
item_id = aes_hex_decrypt(settings.SECRET_KEY, kwargs['item_crypto_id'])
except DecryptionError:
raise Http404()
try:
data = regie.get_invoice_pdf(request.user, item_id)
except PermissionDenied:
return HttpResponseForbidden()
except DecryptionError as e:
return Http404(str(e))
if data.status_code != 200:
logging.error('failed to retrieve invoice (%r)', data.status_code)
messages.error(request, _('We are sorry but an error occured when retrieving the invoice.'))
if self.request.META.get('HTTP_REFERER'):
return HttpResponseRedirect(self.request.META.get('HTTP_REFERER'))
return HttpResponseRedirect('/')
2015-09-03 16:03:44 +02:00
r = HttpResponse(data, content_type='application/pdf')
r['Content-Disposition'] = 'attachment; filename="%s.pdf"' % item_id
2015-09-03 16:03:44 +02:00
return r
2015-09-04 10:41:57 +02:00
class ItemView(TemplateView):
http_method_names = [u'get']
def get_context_data(self, **kwargs):
ret = {'item_url': self.request.get_full_path()}
try:
regie = Regie.objects.get(pk=kwargs['regie_id'])
except Regie.DoesNotExist:
raise Http404()
try:
item_id = aes_hex_decrypt(settings.SECRET_KEY, kwargs['item_crypto_id'])
except DecryptionError:
raise Http404()
item = regie.get_invoice(self.request.user, item_id)
if not item:
raise Http404(_('No item was found.'))
ret.update({'item': item, 'regie': regie})
return ret
def get_template_names(self):
if self.request.is_ajax:
return ['lingo/combo/item.html']
return ['lingo/combo/invoice_fullpage.html']
class CancelItemView(DetailView):
model = BasketItem
template_name = 'lingo/combo/cancel-item.html'
def get_context_data(self, **kwargs):
context = super(CancelItemView, self).get_context_data(**kwargs)
context['basket_url'] = get_basket_url()
return context
def get_queryset(self):
user = self.request.user if self.request.user.is_authenticated() else None
return BasketItem.get_items_to_be_paid(user=user)
def post(self, request, *args, **kwargs):
if not request.user.is_authenticated():
messages.error(request, _('An error occured when removing the item. '
'(no authenticated user)'))
return HttpResponseRedirect(get_basket_url())
if not self.get_object().user_cancellable:
messages.error(request, _('This item cannot be removed.'))
return HttpResponseRedirect(get_basket_url())
try:
self.get_object().notify_cancellation(notify_origin=True)
except requests.exceptions.HTTPError:
messages.error(request, _('An error occured when removing the item.'))
return HttpResponseRedirect(get_basket_url())
class SelfInvoiceView(View):
http_method_names = ['get', 'options']
@csrf_exempt
def dispatch(self, *args, **kwargs):
return super(SelfInvoiceView, self).dispatch(*args, **kwargs)
def get(self, request, *args, **kwargs):
try:
obj = SelfDeclaredInvoicePayment.objects.get(id=kwargs['cell_id'])
except SelfDeclaredInvoicePayment.DoesNotExist:
raise Http404()
invoice_id = request.GET.get('invoice-number', '')
invoice_amount = request.GET.get('invoice-amount', '').replace(',', '.')
msg = None
url = None
try:
invoice_amount = Decimal(invoice_amount)
except ArithmeticError:
invoice_amount = '-'
msg = _('Sorry, the provided amount is invalid.')
else:
for regie in obj.get_regies():
try:
invoice = regie.get_invoice(None, invoice_id, log_errors=False)
except ObjectDoesNotExist:
continue
if invoice.total_amount != invoice_amount:
continue
url = reverse('view-item', kwargs={
'regie_id': regie.id, 'item_crypto_id': invoice.crypto_id})
break
else:
msg = _('Sorry, no invoice were found with that number and amount.')
if request.GET.get('ajax') == 'on':
response = HttpResponse(content_type='application/json')
response.write(json.dumps({'url': url, 'msg': msg and force_text(msg)}))
return response
if url:
return HttpResponseRedirect(url)
messages.warning(request, msg)
return HttpResponseRedirect(request.GET.get('page_path') or '/')