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 %}
+
+{% 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 %}