From f09b5408ae2c17f5ef384ef93ea0fabef8aaa399 Mon Sep 17 00:00:00 2001 From: Dmitriy Shesterkin Date: Sat, 15 Jul 2017 17:21:51 +0300 Subject: [PATCH] test robokassa --- src/customer/admin.py | 7 + src/customer/migrations/0006_payment.py | 30 +++ .../migrations/0007_payment_status.py | 19 ++ src/customer/models.py | 62 ++++- src/customer/urls.py | 12 +- src/customer/views/license.py | 34 ++- src/dokumentor/settings/common.py | 35 +-- src/dokumentor/settings/stage.py | 7 + src/dokumentor/urls.py | 1 + src/robokassa/__init__.py | 30 +++ src/robokassa/apps.py | 9 + src/robokassa/conf.py | 25 ++ src/robokassa/forms.py | 199 ++++++++++++++++ src/robokassa/migrations/0001_initial.py | 27 +++ src/robokassa/migrations/__init__.py | 0 src/robokassa/models.py | 22 ++ src/robokassa/signals.py | 6 + src/robokassa/tests.py | 214 ++++++++++++++++++ src/robokassa/urls.py | 15 ++ src/robokassa/views.py | 99 ++++++++ templates/robokassa/error.html | 6 + templates/robokassa/fail.html | 4 + templates/robokassa/form.html | 7 + templates/robokassa/success.html | 4 + 24 files changed, 847 insertions(+), 27 deletions(-) create mode 100644 src/customer/migrations/0006_payment.py create mode 100644 src/customer/migrations/0007_payment_status.py create mode 100644 src/robokassa/__init__.py create mode 100644 src/robokassa/apps.py create mode 100644 src/robokassa/conf.py create mode 100644 src/robokassa/forms.py create mode 100644 src/robokassa/migrations/0001_initial.py create mode 100644 src/robokassa/migrations/__init__.py create mode 100644 src/robokassa/models.py create mode 100644 src/robokassa/signals.py create mode 100644 src/robokassa/tests.py create mode 100644 src/robokassa/urls.py create mode 100644 src/robokassa/views.py create mode 100644 templates/robokassa/error.html create mode 100644 templates/robokassa/fail.html create mode 100644 templates/robokassa/form.html create mode 100644 templates/robokassa/success.html diff --git a/src/customer/admin.py b/src/customer/admin.py index b750c20..d717df1 100644 --- a/src/customer/admin.py +++ b/src/customer/admin.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from django.contrib import admin from django.utils import timezone +from robokassa.models import SuccessNotification from customer import forms from customer import models @@ -101,7 +102,13 @@ class ClientAdmin(admin.ModelAdmin): ] +@admin.register(models.Payment) +class PaymentAdmin(admin.ModelAdmin): + pass + + admin.site.register(models.UserProfile, UserProfileAdmin) admin.site.register(models.BankAccount, BankAccountAdmin) admin.site.register(models.Client, ClientAdmin) admin.site.register(models.LicensePrice) +admin.site.register(SuccessNotification) diff --git a/src/customer/migrations/0006_payment.py b/src/customer/migrations/0006_payment.py new file mode 100644 index 0000000..45b8244 --- /dev/null +++ b/src/customer/migrations/0006_payment.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('customer', '0005_auto_20170629_1224'), + ] + + operations = [ + migrations.CreateModel( + name='Payment', + fields=[ + ('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)), + ('order_amount', models.DecimalField(verbose_name='Сумма заказа', max_digits=15, decimal_places=2)), + ('order_number', models.IntegerField(verbose_name='Номер заказа')), + ('created', models.DateTimeField(verbose_name='Время создания', auto_now_add=True)), + ('user', models.ForeignKey(verbose_name='Пользователь', related_name='payment_user', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Платеж', + 'verbose_name_plural': 'Платежи', + }, + ), + ] diff --git a/src/customer/migrations/0007_payment_status.py b/src/customer/migrations/0007_payment_status.py new file mode 100644 index 0000000..9b84202 --- /dev/null +++ b/src/customer/migrations/0007_payment_status.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customer', '0006_payment'), + ] + + operations = [ + migrations.AddField( + model_name='payment', + name='status', + field=models.PositiveSmallIntegerField(verbose_name='Статус', default=0, choices=[(0, 'Ожидает оплаты'), (1, 'Оплачен'), (2, 'Отклонен')]), + ), + ] diff --git a/src/customer/models.py b/src/customer/models.py index 84b7197..9c7e057 100644 --- a/src/customer/models.py +++ b/src/customer/models.py @@ -4,7 +4,6 @@ import os import logging from datetime import datetime, timedelta -from django.utils import timezone from PIL import Image from pytils import numeral @@ -13,11 +12,14 @@ from dateutil.relativedelta import relativedelta from django.db import models from django.db.models import Max from django.core.urlresolvers import reverse +from django.utils import timezone +from django.utils.deconstruct import deconstructible +from django.conf import settings from customer import consts, managers, utils from myauth.models import DokUser from commons.utils import only_numerics -from django.utils.deconstruct import deconstructible +from robokassa.signals import result_received log = logging.getLogger(__name__) @@ -571,13 +573,19 @@ class License(models.Model): numeral.choose_plural(self.pay_sum, "рубль, рубля, рублей"), ) + def get_payment_id(self): + if self.payform == 1: + payments = Payment.objects.filter(order_number=self.id) + if payments: + return payments.first().id + # TODO: test def save(self, *args, **kwargs): if not self.__prev_date and self.paid_date: max_date_license = License.objects.\ filter(company=self.company).aggregate(Max('date_to'))['date_to__max'] - today = timedelta.now().date() + today = datetime.now().date() if max_date_license < today: max_date_license = today - timedelta(1) self.date_from = max_date_license + relativedelta(days=1) @@ -604,7 +612,12 @@ class License(models.Model): return f'Оплата безналичным платежом' # redirect to pay terminal with data elif self.payform == 1: - return f'Оплата банковской картой' + if self.get_payment_id(): + url = reverse('payment_robokassa', + kwargs={'payment_id': self.get_payment_id()}) + else: + url = '#' + return f'Оплата банковской картой' elif self.status in [1, 2]: url = reverse( @@ -706,3 +719,44 @@ class LicensePrice(models.Model): def __str__(self): return f'{self.term} {numeral.choose_plural(self.term, "месяц, месяца, месяцев")} ' \ f'({self.price} {numeral.choose_plural(self.price, "рубль, рубля, рублей")})' + + +class Payment(models.Model): + PROCESSED = 0 + SUCCESS = 1 + FAIL = 2 + + CHOICES = ( + (PROCESSED, 'Ожидает оплаты'), + (SUCCESS, 'Оплачен'), + (FAIL, 'Отклонен'), + ) + + order_amount = models.DecimalField('Сумма заказа', max_digits=15, decimal_places=2) + order_number = models.IntegerField('Номер заказа') + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name='payment_user', + verbose_name='Пользователь' + ) + created = models.DateTimeField('Время создания', auto_now_add=True) + status = models.PositiveSmallIntegerField( + verbose_name='Статус', + choices=CHOICES, + default=PROCESSED + ) + + class Meta: + verbose_name = 'Платеж' + verbose_name_plural = 'Платежи' + + def __str__(self): + return f'{self.user}-{self.order_number}' + + +def payment_received(sender, **kwargs): + print(kwargs) + + +result_received.connect(payment_received) + diff --git a/src/customer/urls.py b/src/customer/urls.py index 53a5f0d..ec2dd54 100644 --- a/src/customer/urls.py +++ b/src/customer/urls.py @@ -17,9 +17,6 @@ urlpatterns = [ url(r'^license/$', license.order_license, name='customer_order_license'), url(r'^delete_license/(?P\d+)/$', license.delete_license, name='customer_delete_license'), - # for delete - # url(r'^get_doc/(?P\d+)/$', documents.get_doc, - # name='customer_license_get_doc'), url( r'^get_payment_account/(?P\d+)/$', @@ -31,13 +28,16 @@ urlpatterns = [ documents.create_certificate_of_completion, name='get_certificate_of_completion' ), + + url( + r'^payment/confirm/(?P\d+)$', + license.pay_with_robokassa, + name='payment_robokassa' + ), # for delete # url(r'^payment/result/$', license.payment_result, name='yamoney_result'), # url(r'^payment/success/$', license.payment_success, name='yamoney_success'), # url(r'^payment/fail/$', license.payment_fail, name='yamoney_fail'), - # for delete - # url(r'^license_list/$', license.license_list, name='customer_license_list'), - # url(r'^paid_list/$', license.paid_list, name='customer_paid_list'), # for delete end url(r'^orders/$', license.orders_list, name='customer-orders'), diff --git a/src/customer/views/license.py b/src/customer/views/license.py index f836625..2e9889a 100644 --- a/src/customer/views/license.py +++ b/src/customer/views/license.py @@ -5,7 +5,7 @@ import hashlib import itertools from django.db.models import Count -from django.shortcuts import render, redirect +from django.shortcuts import render, redirect, get_object_or_404 from django.http import (HttpResponseForbidden, HttpResponse, HttpResponseBadRequest) from django.conf import settings from django.views.decorators.csrf import csrf_exempt @@ -13,8 +13,9 @@ from django.contrib.auth.decorators import login_required from django.template.response import TemplateResponse from django.core.urlresolvers import reverse from django.views.decorators.csrf import csrf_protect +from robokassa.forms import RobokassaForm -from customer.models import License, LicensePrice +from customer.models import License, LicensePrice, Payment from customer.forms import LicenseForm from customer.utils import raise_if_no_profile @@ -41,7 +42,13 @@ def order_license(request): pay_sum=form.cleaned_data['term'].price ) new_license.save() - + if form.cleaned_data['payform'] == '1': + payment = Payment.objects.create( + order_amount=form.cleaned_data['term'].price, + order_number=new_license.id, + user=request.user + ) + return redirect(reverse('payment_robokassa', kwargs={'payment_id': payment.id})) return redirect(reverse('customer-orders')) return render(request, template_name, dictionary) @@ -166,3 +173,24 @@ def orders_list(request): 'object_list': merged } return render(request, template_name, context) + + +@login_required +def pay_with_robokassa(request, payment_id): + payment = get_object_or_404(Payment, pk=payment_id) + + # https://auth.robokassa.ru/Merchant/Index.aspx?isTest=1&MerchantLogin=Dokumentor&InvId=231849535&OutSum=100.00&SignatureValue=5c330dbb455ab540fb5fb4ee6b476f3a&Culture=ru + # http://127.0.0.1:8000/robokassa/success/?inv_id=3&InvId=3&out_summ=200.00&OutSum=200.00&crc=2c5050aaff3788c19cbd50ac6974f650&SignatureValue=2c5050aaff3788c19cbd50ac6974f650&Culture=ru&IsTest=1 + # http://127.0.0.1:8000/robokassa/success/?inv_id=5&InvId=5&out_summ=3000.00&OutSum=3000.00&crc=3e9ae74938ad9397053bf184732e8bfa&SignatureValue=3e9ae74938ad9397053bf184732e8bfa&Culture=ru&IsTest=1 + # 29CE5970A619428DE1E800A49E68E13B + # 3e9ae74938ad9397053bf184732e8bfa + + form = RobokassaForm(initial={ + 'OutSum': payment.order_amount, + 'InvId': payment.id, + 'Desc': 'Оплата лицензии Dokumentor.ru', + 'Email': payment.user.email, + 'Culture': 'ru' + }) + + return render(request, 'robokassa/form.html', {'form': form}) diff --git a/src/dokumentor/settings/common.py b/src/dokumentor/settings/common.py index d3bd7cc..4cb5b20 100644 --- a/src/dokumentor/settings/common.py +++ b/src/dokumentor/settings/common.py @@ -163,6 +163,7 @@ INSTALLED_APPS = [ 'treebeard', 'djangocms_text_ckeditor', 'raven.contrib.django.raven_compat', + 'robokassa', ] LOCAL_APPS = [ @@ -204,18 +205,18 @@ LANGUAGES = ( CMS_TEMPLATES = ( ('pages/index.html', 'Index Page'), - ('pages/inner_page.html', u'Внутренняя страница'), - ('pages/placeholders.html', u'Блоки для других страниц'), + ('pages/inner_page.html', 'Внутренняя страница'), + ('pages/placeholders.html', 'Блоки для других страниц'), ) CMS_PLACEHOLDER_CONF = { 'why_register': { 'plugins': ['CMSDescTextBlockPlugin'], - 'name': u'Зачем регистрироваться', + 'name': 'Зачем регистрироваться', }, 'reasons': { 'plugins': ['CMSExtendedTextBlockPlugin'], - 'name': u'На главной - причины', + 'name': 'На главной - причины', }, } @@ -231,7 +232,7 @@ CELERY_TIMEZONE = 'Europe/Moscow' CALLBACK_SETTINGS = { 'EMAIL_SENDER': e.get('CALLBACK_EMAIL_SENDER'), 'MANAGERS_EMAILS': e.get('CALLBACK_MANAGERS_EMAILS'), - 'NEW_REQ_AVAIL_EMAIL_SUBJ': u'Вопрос техподдержке', + 'NEW_REQ_AVAIL_EMAIL_SUBJ': 'Вопрос техподдержке', } CELERYBEAT_SCHEDULE = { @@ -280,14 +281,6 @@ THUMBNAIL_PROCESSORS = ( 'easy_thumbnails.processors.filters' ) -RAVEN_CONFIG = { - 'dsn': 'http://02d524ef0d044bdfae0b39546b752cb2:' - '1e025305594d4532ae93125372dcde50@sentry.mitri4.pro/1', - # If you are using git, you can also automatically configure the - # release based on the git info. - 'release': raven.fetch_git_sha(ROOT_DIR), -} - # cache settings COMMON_CACHE_PREFIX = 'dokumentor_' CMS_CACHE_PREFIX = '%scms-' % COMMON_CACHE_PREFIX @@ -321,12 +314,26 @@ OWNER = { 'CORR_ACC': '30101810600000000786', 'POSITION_BOSS': 'Руководитель организации', 'BOSS': 'Костенко А.Ю', - 'GB': 'Костенко Д.Ю', + 'GB': 'Костенко А.Ю', 'STAMP': os.path.join(ROOT_DIR, 'extra', 'stamp.png'), 'SIGN_BOSS': os.path.join(ROOT_DIR, 'extra', 'boss_sign.png'), 'SIGN_GB': os.path.join(ROOT_DIR, 'extra', 'gb_sign.png') } +# ROBOKASSA_PASSWORD1 = 'O9ohc2uT8T9s1fKwXbuq' +# ROBOKASSA_PASSWORD2 = 'D552KBeAJhhVOfKEqT62' + + +# Robokassa +ROBOKASSA_LOGIN = 'Dokumentor' +# Test +ROBOKASSA_PASSWORD1 = 'ZtcV4jzgJ5qI2Cx7Rc4a' +ROBOKASSA_PASSWORD2 = 'iuJI7adaUGGE96QKz17a' + +ROBOKASSA_USE_POST = False +ROBOKASSA_STRICT_CHECK = True +ROBOKASSA_TEST_MODE = True + LOGGING = { 'version': 1, 'disable_existing_loggers': True, diff --git a/src/dokumentor/settings/stage.py b/src/dokumentor/settings/stage.py index 2a61ce5..a8c75f0 100644 --- a/src/dokumentor/settings/stage.py +++ b/src/dokumentor/settings/stage.py @@ -21,6 +21,13 @@ DATABASES = { 'default': dj_database_url.parse('postgres://dokumentor:dokumentor@db:5432/dokumentor'), } +RAVEN_CONFIG = { + 'dsn': 'http://02d524ef0d044bdfae0b39546b752cb2:' + '1e025305594d4532ae93125372dcde50@sentry.mitri4.pro/1', + # If you are using git, you can also automatically configure the + # release based on the git info. + 'release': raven.fetch_git_sha(ROOT_DIR), +} DEFAULT_FROM_EMAIL = 'Открытые технологии ' SERVER_EMAIL = DEFAULT_FROM_EMAIL diff --git a/src/dokumentor/urls.py b/src/dokumentor/urls.py index e26af0c..e9ef30d 100644 --- a/src/dokumentor/urls.py +++ b/src/dokumentor/urls.py @@ -29,6 +29,7 @@ urlpatterns = [ url(r'^user/', include('myauth.urls')), url(r'^captcha/', include('captcha.urls')), + url(r'^robokassa/', include('robokassa.urls')), url(r'^', include('cms.urls')), ] diff --git a/src/robokassa/__init__.py b/src/robokassa/__init__.py new file mode 100644 index 0000000..31f439f --- /dev/null +++ b/src/robokassa/__init__.py @@ -0,0 +1,30 @@ +import sys + + +VERSION = (1, 3, 3) + + +def get_version(): + return '.'.join(map(str, VERSION)) + + +PY2 = sys.version_info[0] == 2 +PY3 = sys.version_info[0] == 3 + + +def to_unicode(value): + try: + return unicode(value) + except NameError: + return str(value) + + +def python_2_unicode_compatible(klass): + if PY2: + if '__str__' not in klass.__dict__: + raise ValueError("@python_2_unicode_compatible cannot be applied " + "to %s because it doesn't define __str__()." % + klass.__name__) + klass.__unicode__ = klass.__str__ + klass.__str__ = lambda self: self.__unicode__().encode('utf-8') + return klass diff --git a/src/robokassa/apps.py b/src/robokassa/apps.py new file mode 100644 index 0000000..38c3497 --- /dev/null +++ b/src/robokassa/apps.py @@ -0,0 +1,9 @@ +# -*- encoding: utf-8 -*- + +from django.utils.translation import ugettext_lazy as _ +from django.apps import AppConfig + + +class DBMailConfig(AppConfig): + name = 'robokassa' + verbose_name = _(u'Робокасса') diff --git a/src/robokassa/conf.py b/src/robokassa/conf.py new file mode 100644 index 0000000..b26d9b9 --- /dev/null +++ b/src/robokassa/conf.py @@ -0,0 +1,25 @@ +# coding: utf-8 + +from django.conf import settings + +# обязательные параметры - реквизиты магазина +LOGIN = settings.ROBOKASSA_LOGIN +PASSWORD1 = settings.ROBOKASSA_PASSWORD1 +PASSWORD2 = getattr(settings, 'ROBOKASSA_PASSWORD2', None) + +# использовать ли метод POST при приеме результатов +USE_POST = getattr(settings, 'ROBOKASSA_USE_POST', True) + +# требовать предварительного уведомления на ResultURL +STRICT_CHECK = getattr(settings, 'ROBOKASSA_STRICT_CHECK', True) + +# тестовый режим +TEST_MODE = getattr(settings, 'ROBOKASSA_TEST_MODE', False) + +# url, по которому будет идти отправка форм +FORM_TARGET = 'https://merchant.roboxchange.com/Index.aspx' +if TEST_MODE: + FORM_TARGET = 'https://auth.robokassa.ru/Merchant/Index.aspx' + +# список пользовательских параметров ("shp" к ним приписывать не нужно) +EXTRA_PARAMS = sorted(getattr(settings, 'ROBOKASSA_EXTRA_PARAMS', [])) diff --git a/src/robokassa/forms.py b/src/robokassa/forms.py new file mode 100644 index 0000000..f4a42e5 --- /dev/null +++ b/src/robokassa/forms.py @@ -0,0 +1,199 @@ +# coding: utf-8 + +from hashlib import md5 +from django import forms + +try: + from urllib import urlencode +except ImportError: + from urllib.parse import urlencode + +from robokassa.conf import LOGIN, PASSWORD1, PASSWORD2 +from robokassa.conf import STRICT_CHECK, FORM_TARGET, EXTRA_PARAMS, TEST_MODE +from robokassa.models import SuccessNotification +from robokassa import to_unicode, PY2 + + +class BaseRobokassaForm(forms.Form): + def __init__(self, *args, **kwargs): + self.password1 = kwargs.pop('password1', PASSWORD1) + self.password2 = kwargs.pop('password2', PASSWORD2) + self.login = kwargs.pop('login', LOGIN) + + super(BaseRobokassaForm, self).__init__(*args, **kwargs) + + # создаем дополнительные поля + for key in EXTRA_PARAMS: + self.fields['shp' + key] = forms.CharField(required=False) + if 'initial' in kwargs: + self.fields['shp' + key].initial = kwargs['initial'].get( + key, 'None') + + @staticmethod + def _append_extra_part(standard_part, value_func): + extra_part = ":".join( + ["%s=%s" % ('shp' + key, value_func('shp' + key)) + for key in EXTRA_PARAMS]) + if extra_part: + return ':'.join([standard_part, extra_part]) + return standard_part + + def extra_params(self): + extra = {} + for param in EXTRA_PARAMS: + if ('shp' + param) in self.cleaned_data: + extra[param] = self.cleaned_data['shp' + param] + return extra + + def _get_signature(self): + if PY2: + return md5(self._get_signature_string()).hexdigest().upper() + return md5( + self._get_signature_string().encode("ascii")).hexdigest().upper() + + def _get_signature_string(self): + raise NotImplementedError + + +class RobokassaForm(BaseRobokassaForm): + # login магазина в обменном пункте + MrchLogin = forms.CharField(max_length=20) + + # требуемая к получению сумма + OutSum = forms.DecimalField( + min_value=0, max_digits=20, decimal_places=2, required=False) + + # номер счета в магазине (должен быть уникальным для магазина) + InvId = forms.IntegerField(min_value=0, required=False) + + # описание покупки + Desc = forms.CharField(max_length=100, required=False) + + # контрольная сумма MD5 + SignatureValue = forms.CharField(max_length=32) + + # предлагаемая валюта платежа + IncCurrLabel = forms.CharField(max_length=10, required=False) + + # e-mail пользователя + Email = forms.CharField(max_length=100, required=False) + + # язык общения с клиентом (en или ru) + Culture = forms.CharField(max_length=10, required=False) + if TEST_MODE: + IsTest = forms.IntegerField(min_value=1, required=False) + + # Параметр с URL'ом, на который форма должны быть отправлена. + # Может пригодиться для использования в шаблоне. + target = FORM_TARGET + + def __init__(self, *args, **kwargs): + super(RobokassaForm, self).__init__(*args, **kwargs) + + # скрытый виджет по умолчанию + for field in self.fields: + self.fields[field].widget = forms.HiddenInput() + + self.fields['MrchLogin'].initial = self.login + self.fields['SignatureValue'].initial = self._get_signature() + if TEST_MODE: + self.fields['IsTest'].initial = 1 + + def get_redirect_url(self): + """ + Получить URL с GET-параметрами, соответствующими значениям полей в + форме. Редирект на адрес, возвращаемый этим методом, эквивалентен + ручной отправке формы методом GET. + """ + + def _initial(key, fld): + val = self.initial.get(key, fld.initial) + if not val: + return val + return to_unicode(val).encode('1251') + + fields = [ + (name, _initial(name, field)) + for name, field in self.fields.items() + if _initial(name, field) + ] + return self.target + '?' + urlencode(fields) + + def _get_signature_string(self): + def _val(name): + if name in self.initial: + value = self.initial[name] + else: + value = self.fields[name].initial + return '' if value is None else to_unicode(value) + + standard_part = ':'.join( + [_val('MrchLogin'), _val('OutSum'), _val('InvId'), self.password1]) + return self._append_extra_part(standard_part, _val) + + +class ResultURLForm(BaseRobokassaForm): + """ + Форма для приема результатов и проверки контрольной суммы. + """ + + OutSum = forms.CharField(max_length=15) + InvId = forms.IntegerField(min_value=0) + SignatureValue = forms.CharField(max_length=32) + + def clean(self): + try: + signature = self.cleaned_data['SignatureValue'].upper() + if signature != self._get_signature(): + raise forms.ValidationError(u'Ошибка в контрольной сумме') + except KeyError: + raise forms.ValidationError(u'Пришли не все необходимые параметры') + + return self.cleaned_data + + def _get_signature_string(self): + _val = lambda name: to_unicode(self.cleaned_data[name]) + standard_part = ':'.join( + [_val('OutSum'), _val('InvId'), self.password2]) + return self._append_extra_part(standard_part, _val) + + +class _RedirectPageForm(ResultURLForm): + """ + Форма для проверки контрольной суммы на странице Success. + """ + + Culture = forms.CharField(max_length=10) + + def _get_signature_string(self): + _val = lambda name: to_unicode(self.cleaned_data[name]) + standard_part = ':'.join( + [_val('OutSum'), _val('InvId'), self.password1]) + return self._append_extra_part(standard_part, _val) + + +class SuccessRedirectForm(_RedirectPageForm): + """ + Форма для обработки страницы Success с дополнительной защитой. Она + проверяет, что ROBOKASSA предварительно уведомила систему о платеже, + отправив запрос на ResultURL. + """ + + def clean(self): + data = super(SuccessRedirectForm, self).clean() + if STRICT_CHECK: + if not SuccessNotification.objects.filter(InvId=data['InvId']): + raise forms.ValidationError( + 'От ROBOKASSA не было предварительного уведомления' + ) + return data + + +class FailRedirectForm(BaseRobokassaForm): + """ + Форма приема результатов для перенаправления на страницу Fail. + """ + + OutSum = forms.CharField(max_length=15) + InvId = forms.IntegerField(min_value=0) + Culture = forms.CharField(max_length=10) diff --git a/src/robokassa/migrations/0001_initial.py b/src/robokassa/migrations/0001_initial.py new file mode 100644 index 0000000..0143d8e --- /dev/null +++ b/src/robokassa/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SuccessNotification', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('InvId', models.IntegerField(verbose_name='\u041d\u043e\u043c\u0435\u0440 \u0437\u0430\u043a\u0430\u0437\u0430', db_index=True)), + ('OutSum', models.CharField(max_length=15, verbose_name='\u0421\u0443\u043c\u043c\u0430')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='\u0414\u0430\u0442\u0430 \u0438 \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f')), + ], + options={ + 'verbose_name': '\u0423\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0435 \u043e\u0431 \u0443\u0441\u043f\u0435\u0448\u043d\u043e\u043c \u043f\u043b\u0430\u0442\u0435\u0436\u0435', + 'verbose_name_plural': '\u0423\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u044f \u043e\u0431 \u0443\u0441\u043f\u0435\u0448\u043d\u044b\u0445 \u043f\u043b\u0430\u0442\u0435\u0436\u0430\u0445 (ROBOKASSA)', + }, + bases=(models.Model,), + ), + ] diff --git a/src/robokassa/migrations/__init__.py b/src/robokassa/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robokassa/models.py b/src/robokassa/models.py new file mode 100644 index 0000000..efddcb3 --- /dev/null +++ b/src/robokassa/models.py @@ -0,0 +1,22 @@ +# coding: utf-8 + +from django.utils.translation import ugettext_lazy as _ +from robokassa import python_2_unicode_compatible +from django.db import models + + +@python_2_unicode_compatible +class SuccessNotification(models.Model): + InvId = models.IntegerField(_(u'Номер заказа'), db_index=True) + OutSum = models.CharField(_(u'Сумма'), max_length=15) + + created_at = models.DateTimeField( + _(u'Дата и время получения уведомления'), auto_now_add=True) + + class Meta: + verbose_name = _(u'Уведомление об успешном платеже') + verbose_name_plural = _( + u'Уведомления об успешных платежах (ROBOKASSA)') + + def __str__(self): + return u'#%d: %s (%s)' % (self.InvId, self.OutSum, self.created_at) diff --git a/src/robokassa/signals.py b/src/robokassa/signals.py new file mode 100644 index 0000000..98abd8c --- /dev/null +++ b/src/robokassa/signals.py @@ -0,0 +1,6 @@ +# coding: utf-8 +from django.dispatch import Signal + +result_received = Signal(providing_args=["InvId", "OutSum"]) +success_page_visited = Signal(providing_args=["InvId", "OutSum"]) +fail_page_visited = Signal(providing_args=["InvId", "OutSum"]) diff --git a/src/robokassa/tests.py b/src/robokassa/tests.py new file mode 100644 index 0000000..e076888 --- /dev/null +++ b/src/robokassa/tests.py @@ -0,0 +1,214 @@ +# coding: utf-8 + +from unittest import TestCase +from django.test import TestCase as DjangoTestCase +from robokassa.forms import RobokassaForm, ResultURLForm +from robokassa.conf import LOGIN, PASSWORD1, PASSWORD2 + + +CUSTOM_LOGIN = 'test_login2' +CUSTOM_PASS1 = 'test_password21' +CUSTOM_PASS2 = 'test_password22' + + +class RobokassaFormTest(TestCase): + def setUp(self): + self.form = RobokassaForm( + initial={ + 'OutSum': 100.00, + 'InvId': 58, + 'Desc': u'Холодильник "Бирюса"', + 'Email': 'vasia@example.com' + }) + + def testSignature(self): + self.assertEqual( + self.form._get_signature_string(), + '%s:100.0:58:%s:shpparam1=None:shpparam2=None' % (LOGIN, PASSWORD1) + ) + self.assertEqual(len(self.form.fields['SignatureValue'].initial), 32) + + def testSignatureMissingParams(self): + form = RobokassaForm(initial={'InvId': 5}) + self.assertEqual( + form._get_signature_string(), + '%s::5:%s:shpparam1=None:shpparam2=None' % (LOGIN, PASSWORD1) + ) + + def testRedirectUrl(self): + url = "https://merchant.roboxchange.com/Index.aspx?MrchLogin=" \ + "test_login&OutSum=100.0&InvId=58&Desc=%D5%EE%EB%EE%E4%" \ + "E8%EB%FC%ED%E8%EA+%22%C1%E8%F0%FE%F1%E0%22&SignatureVa" \ + "lue=0EC23BE40003640B35EC07F6615FFB57&Email=vasia%40exa" \ + "mple.com&shpparam1=None&shpparam2=None" + self.assertEqual(self.form.get_redirect_url(), url) + + +class RobokassaFormExtraTest(TestCase): + def testExtra(self): + form = RobokassaForm( + initial={ + 'InvId': 58, + 'OutSum': 100, + 'param1': 'value1', + 'param2': 'value2' + }) + self.assertEqual( + form._get_signature_string(), + '%s:100:58:%s:shpparam1=value1:shpparam2=value2' % ( + LOGIN, PASSWORD1) + ) + + +class ResultURLTest(DjangoTestCase): + def testFormExtra(self): + form = ResultURLForm({ + 'OutSum': '100', + 'InvId': '58', + 'SignatureValue': 'B2111A06F6B7A1E090D38367BF7032D9', + 'shpparam1': 'Vasia', + 'shpparam2': 'None', + }) + self.assertTrue(form.is_valid()) + self.assertEqual( + form._get_signature_string(), + '100:58:%s:shpparam1=Vasia:shpparam2=None' % PASSWORD2) + self.assertEqual( + form.extra_params(), {'param1': 'Vasia', 'param2': 'None'} + ) + + def testFormValid(self): + self.assertTrue(ResultURLForm({ + 'OutSum': '100', + 'InvId': '58', + 'SignatureValue': '877D3BAF8381F70E56638C3BC82580C5', + 'shpparam1': 'None', + 'shpparam2': 'None', + }).is_valid()) + + self.assertFalse(ResultURLForm({ + 'OutSum': '101', + 'InvId': '58', + 'SignatureValue': '877D3BAF8381F70E56638C3BC82580C5', + 'shpparam1': 'None', + 'shpparam2': 'None', + }).is_valid()) + + self.assertFalse(ResultURLForm({ + 'OutSum': '100', + 'InvId': '58', + 'SignatureValue': '877D3BAF8381F70E56638C3BC82580C5', + 'shpparam1': 'Vasia', + 'shpparam2': 'None', + }).is_valid()) + + def testEmptyFormValid(self): + self.assertFalse(ResultURLForm().is_valid()) + + +class RobokassaCustomCredentialFormTest(TestCase): + def setUp(self): + self.form = RobokassaForm( + initial={ + 'OutSum': 100.00, + 'InvId': 58, + 'Desc': u'Холодильник "Бирюса"', + 'Email': 'vasia@example.com' + }, + login=CUSTOM_LOGIN, password1=CUSTOM_PASS1, password2=CUSTOM_PASS2 + ) + + def testSignature(self): + self.assertEqual( + self.form._get_signature_string(), + '%s:100.0:58:%s:shpparam1=None:shpparam2=None' % ( + CUSTOM_LOGIN, CUSTOM_PASS1) + ) + self.assertEqual(len(self.form.fields['SignatureValue'].initial), 32) + + def testSignatureMissingParams(self): + form = RobokassaForm( + initial={'InvId': 5}, + login=CUSTOM_LOGIN, password1=CUSTOM_PASS1, password2=CUSTOM_PASS2) + self.assertEqual( + form._get_signature_string(), + '%s::5:%s:shpparam1=None:shpparam2=None' % ( + CUSTOM_LOGIN, CUSTOM_PASS1) + ) + + def testRedirectUrl(self): + url = "https://merchant.roboxchange.com/Index.aspx?MrchLogin=" \ + "test_login2&OutSum=100.0&InvId=58&Desc=%D5%EE%EB%EE%E4%E8%" \ + "EB%FC%ED%E8%EA+%22%C1%E8%F0%FE%F1%E0%22&SignatureValue=659" \ + "9E0D576E94D4E8616A40B16B8288F&Email=vasia%40example.com&sh" \ + "pparam1=None&shpparam2=None" + self.assertEqual(self.form.get_redirect_url(), url) + + +class RobokassaCustomCredentialFormExtraTest(TestCase): + def testExtra(self): + form = RobokassaForm( + initial={ + 'InvId': 58, + 'OutSum': 100, + 'param1': 'value1', + 'param2': 'value2' + }, + login=CUSTOM_LOGIN, password1=CUSTOM_PASS1, password2=CUSTOM_PASS2 + ) + self.assertEqual( + form._get_signature_string(), + '%s:100:58:%s:shpparam1=value1:shpparam2=value2' % ( + CUSTOM_LOGIN, CUSTOM_PASS1) + ) + + +class RobokassaCustomCredentialResultURLTest(DjangoTestCase): + def testFormExtra(self): + form = ResultURLForm({ + 'OutSum': '100', + 'InvId': '58', + 'SignatureValue': '8D63137C2AC89F7D5B7E6CD448DA64AF', + 'shpparam1': 'Vasia', + 'shpparam2': 'None', + }, login=CUSTOM_LOGIN, password1=CUSTOM_PASS1, password2=CUSTOM_PASS2) + self.assertTrue(form.is_valid()) + self.assertEqual( + form._get_signature_string(), + '100:58:%s:shpparam1=Vasia:shpparam2=None' % CUSTOM_PASS2) + self.assertEqual( + form.extra_params(), {'param1': 'Vasia', 'param2': 'None'} + ) + + def testFormValid(self): + self.assertTrue(ResultURLForm({ + 'OutSum': '100', + 'InvId': '58', + 'SignatureValue': '3D7DE0A71282E50308ED401DC24BBD5B', + 'shpparam1': 'None', + 'shpparam2': 'None', + }, login=CUSTOM_LOGIN, password1=CUSTOM_PASS1, password2=CUSTOM_PASS2 + ).is_valid()) + + self.assertFalse(ResultURLForm({ + 'OutSum': '101', + 'InvId': '58', + 'SignatureValue': '3D7DE0A71282E50308ED401DC24BBD5B', + 'shpparam1': 'None', + 'shpparam2': 'None', + }, login=CUSTOM_LOGIN, password1=CUSTOM_PASS1, password2=CUSTOM_PASS2 + ).is_valid()) + + self.assertFalse(ResultURLForm({ + 'OutSum': '100', + 'InvId': '58', + 'SignatureValue': '3D7DE0A71282E50308ED401DC24BBD5B', + 'shpparam1': 'Vasia', + 'shpparam2': 'None', + }, login=CUSTOM_LOGIN, password1=CUSTOM_PASS1, password2=CUSTOM_PASS2 + ).is_valid()) + + def testEmptyFormValid(self): + self.assertFalse(ResultURLForm( + login=CUSTOM_LOGIN, password1=CUSTOM_PASS1, password2=CUSTOM_PASS2 + ).is_valid()) diff --git a/src/robokassa/urls.py b/src/robokassa/urls.py new file mode 100644 index 0000000..46b7f7d --- /dev/null +++ b/src/robokassa/urls.py @@ -0,0 +1,15 @@ +# coding: utf-8 + +try: + from django.conf.urls.defaults import url +except ImportError: + from django.conf.urls import url + +from . import views as v + + +urlpatterns = [ + url(r'^result/$', v.receive_result, name='robokassa_result'), + url(r'^success/$', v.success, name='robokassa_success'), + url(r'^fail/$', v.fail, name='robokassa_fail'), +] diff --git a/src/robokassa/views.py b/src/robokassa/views.py new file mode 100644 index 0000000..1868755 --- /dev/null +++ b/src/robokassa/views.py @@ -0,0 +1,99 @@ +# coding: utf-8 + +from django.http import HttpResponse +from django.template.response import TemplateResponse +from django.views.decorators.csrf import csrf_exempt + +from robokassa.conf import USE_POST +from robokassa.forms import ( + ResultURLForm, SuccessRedirectForm, FailRedirectForm) +from robokassa.models import SuccessNotification +from robokassa.signals import ( + result_received, success_page_visited, fail_page_visited) + + +@csrf_exempt +def receive_result(request, **credentials): + """ + Обработчик для ResultURL + """ + data = request.POST if USE_POST else request.GET + form = ResultURLForm(data, **credentials) + print(form.is_valid()) + if form.is_valid(): + inv_id = form.cleaned_data['InvId'] + out_sum = form.cleaned_data['OutSum'] + print('----------------------------------------------------') + print(form.cleaned_data['InvId'], form.cleaned_data['OutSum']) + # сохраняем данные об успешном уведомлении в базе, чтобы + # можно было выполнить дополнительную проверку на странице успешного + # заказа + notification = SuccessNotification.objects.create( + InvId=inv_id, OutSum=out_sum + ) + + # дополнительные действия с заказом (например, смену его статуса) можно + # осуществить в обработчике сигнала robokassa.signals.result_received + result_received.send( + sender=notification, InvId=inv_id, OutSum=out_sum, + extra=form.extra_params()) + + return HttpResponse('OK%s' % inv_id) + return HttpResponse('error: bad signature') + + +@csrf_exempt +def success(request, template_name='robokassa/success.html', + extra_context=None, + error_template_name='robokassa/error.html', **credentials): + """ + Обработчик для SuccessURL. + """ + + data = request.POST if USE_POST else request.GET + form = SuccessRedirectForm(data, **credentials) + if form.is_valid(): + inv_id = form.cleaned_data['InvId'] + out_sum = form.cleaned_data['OutSum'] + + # в случае, когда не используется строгая проверка, действия с заказом + # можно осуществлять в обработчике сигнала + # robokassa.signals.success_page_visited + success_page_visited.send( + sender=form, InvId=inv_id, OutSum=out_sum, + extra=form.extra_params()) + + context = {'InvId': inv_id, 'OutSum': out_sum, 'form': form} + context.update(form.extra_params()) + context.update(extra_context or {}) + return TemplateResponse(request, template_name, context) + + return TemplateResponse(request, error_template_name, {'form': form}) + + +@csrf_exempt +def fail(request, template_name='robokassa/fail.html', extra_context=None, + error_template_name='robokassa/error.html', **credentials): + """ + Обработчик для FailURL. + """ + + data = request.POST if USE_POST else request.GET + form = FailRedirectForm(data, **credentials) + if form.is_valid(): + inv_id = form.cleaned_data['InvId'] + out_sum = form.cleaned_data['OutSum'] + + # дополнительные действия с заказом (например, смену его статуса для + # разблокировки товара на складе) можно осуществить в обработчике + # сигнала robokassa.signals.fail_page_visited + fail_page_visited.send( + sender=form, InvId=inv_id, OutSum=out_sum, + extra=form.extra_params()) + + context = {'InvId': inv_id, 'OutSum': out_sum, 'form': form} + context.update(form.extra_params()) + context.update(extra_context or {}) + return TemplateResponse(request, template_name, context) + + return TemplateResponse(request, error_template_name, {'form': form}) diff --git a/templates/robokassa/error.html b/templates/robokassa/error.html new file mode 100644 index 0000000..ec2ea12 --- /dev/null +++ b/templates/robokassa/error.html @@ -0,0 +1,6 @@ +{% extends 'base.html' %} +{% block content %} +Ошибка при оплате + {{ form.as_p }} + +{% endblock content %} diff --git a/templates/robokassa/fail.html b/templates/robokassa/fail.html new file mode 100644 index 0000000..404a15b --- /dev/null +++ b/templates/robokassa/fail.html @@ -0,0 +1,4 @@ +{% extends 'base.html' %} +{% block content %} +Неудачная оплата +{% endblock content %} diff --git a/templates/robokassa/form.html b/templates/robokassa/form.html new file mode 100644 index 0000000..a7a9025 --- /dev/null +++ b/templates/robokassa/form.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% block content %} +
+

{{ form.as_p }}

+

+
+{% endblock %} diff --git a/templates/robokassa/success.html b/templates/robokassa/success.html new file mode 100644 index 0000000..d7d1c23 --- /dev/null +++ b/templates/robokassa/success.html @@ -0,0 +1,4 @@ +{% extends 'base.html' %} +{% block content %} +Оплата успешна +{% endblock content %}