Skip to content

Commit 0716130

Browse files
author
Mikhail Pyrev
committedMar 26, 2019
Initial
0 parents  commit 0716130

35 files changed

+1003
-0
lines changed
 

‎.gitignore

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
__pycache__/
2+
.idea/
3+
/build/
4+
/dist/
5+
*.sqlite3
6+
local_settings.py
7+
*.egg-info/

‎LICENSE.md

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2019 Mikhail Pyrev
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
python-capitalist
2+
=================
3+
4+
A little client for API of [capitalist.net](https://capitalist.net/).
5+
6+
Couple of API methods are implemented because I need only them.
7+
Feel free to ask for additional methods. I'll look into it.
8+
9+
`import_batch_advanced` DOES NOT support certificates with passphrase.
10+
11+
12+
LICENSE
13+
-------
14+
15+
MIT

‎capitalist/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .capitalist import Capitalist
2+
from .const import __version__

‎capitalist/auth.py

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from base64 import b64encode
2+
3+
from cryptography.hazmat.backends import default_backend
4+
from cryptography.hazmat.primitives import serialization, hashes
5+
from cryptography.hazmat.primitives.asymmetric import padding
6+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
7+
8+
from .exceptions import RequestException
9+
from .utils import retry
10+
11+
12+
class Authenticator:
13+
def __init__(self, request_executor, login, password):
14+
self.request_executor = request_executor
15+
self.login = login
16+
self.password = password
17+
self._encrypted_password = None
18+
self._token = None
19+
20+
@retry((RequestException,))
21+
def _get_token(self):
22+
data = {
23+
'operation': 'get_token',
24+
'login': self.login,
25+
}
26+
json_data = self.request_executor.request(data=data)
27+
return json_data
28+
29+
def setup(self):
30+
token_response = self._get_token()
31+
self._token = token_response['data']['token']
32+
self._encrypted_password = EncryptedPassword(
33+
self.password, token_response['data']['modulus'], token_response['data']['exponent'])
34+
35+
@property
36+
def encrypted_password(self):
37+
if self._encrypted_password is None:
38+
self.setup()
39+
return self._encrypted_password
40+
41+
@property
42+
def token(self):
43+
if self._token is None:
44+
self.setup()
45+
return self._token
46+
47+
48+
class EncryptedPassword:
49+
"""
50+
PKCS1 v1.5 encrypted password.
51+
"""
52+
53+
def __init__(self, password, modulus, exponent):
54+
self._password = password.encode('utf-8')
55+
modulus = int(modulus, 16)
56+
exponent = int(exponent, 16)
57+
public_numbers = RSAPublicNumbers(exponent, modulus)
58+
self.public_key = public_numbers.public_key(default_backend())
59+
60+
@property
61+
def password(self):
62+
return self.public_key.encrypt(self._password, padding.PKCS1v15()).hex()
63+
64+
65+
class Signer:
66+
def __init__(self, private_key: bytes):
67+
self._key = serialization.load_pem_private_key(private_key, None, default_backend())
68+
69+
def sign(self, message):
70+
signature = self._key.sign(message, padding.PKCS1v15(), hashes.SHA1())
71+
signature = b64encode(signature)
72+
return signature

‎capitalist/capitalist.py

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from decimal import Decimal
2+
from pathlib import Path
3+
from typing import Union
4+
5+
from capitalist.exceptions import ResponseException
6+
from capitalist.models import Account, CurrencyRate
7+
from .auth import Authenticator, Signer
8+
from .exceptions import RequestException, ImproperlyConfigured
9+
from .request_executor import RequestExecutor
10+
from .utils import retry
11+
12+
13+
class Capitalist:
14+
def __init__(
15+
self,
16+
login,
17+
password,
18+
request_executor_class=RequestExecutor,
19+
authenticator_class=Authenticator,
20+
signer_class=Signer,
21+
private_key: Union[bytes, Path] = None,
22+
):
23+
self.login = login
24+
self.request_executor = request_executor_class()
25+
self.authenticator = authenticator_class(self.request_executor, login, password)
26+
self.signer = None
27+
if private_key is not None:
28+
if isinstance(private_key, Path):
29+
private_key = private_key.read_bytes()
30+
self.signer = signer_class(private_key)
31+
32+
@retry((RequestException,))
33+
def secure_request(self, operation, data=None, **kwargs):
34+
if data is None:
35+
data = {}
36+
data.update({
37+
'operation': operation,
38+
'login': self.login,
39+
'token': self.authenticator.token,
40+
'encrypted_password': self.authenticator.encrypted_password.password,
41+
})
42+
kwargs['data'] = data
43+
response_json = self.request_executor.request(**kwargs)
44+
code = response_json['code']
45+
if code != 0:
46+
raise ResponseException(code, response_json['message'])
47+
return response_json
48+
49+
def accounts(self):
50+
json_data = self.secure_request('get_accounts')
51+
return [Account.parse_json(acc_json) for acc_json in json_data['data']['accounts']]
52+
53+
def currency_rates(self):
54+
json_data = self.secure_request('currency_rates')['data']['rates']
55+
rates = []
56+
for type_ in CurrencyRate.TYPES:
57+
for rate_data in json_data[type_]:
58+
rates.append(CurrencyRate.parse_json(rate_data, type_=type_))
59+
return rates
60+
61+
def import_batch_advanced(self, payments, account_rur, account_usd, account_eur, account_btc):
62+
if self.signer is None:
63+
raise ImproperlyConfigured('Provide private key and/or passphrase to be able to sign data.')
64+
65+
payment_data = '\n'.join([payment.as_batch_record() for payment in payments])
66+
data = {
67+
'batch': payment_data,
68+
'verification_type': 'SIGNATURE',
69+
'verification_data': self.signer.sign(payment_data.encode('utf-8')),
70+
'account_RUR': account_rur,
71+
'account_USD': account_usd,
72+
'account_EUR': account_eur,
73+
'account_BTC': account_btc,
74+
}
75+
return self.secure_request('import_batch_advanced', data)
76+
77+
def get_document_fee(
78+
self, document_type: str, source_account: str, amount: Decimal, dest_account: str = None,
79+
wiretag: str = None):
80+
data = {
81+
'document_type': document_type,
82+
'source_account': source_account,
83+
'amount': amount,
84+
}
85+
if dest_account:
86+
data['dest_account'] = dest_account
87+
if wiretag:
88+
data['wiretag'] = wiretag
89+
return self.secure_request('get_document_fee', data)

‎capitalist/const.py

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
__version__ = '1.0.0'
2+
3+
TIMEOUT = 15
4+
API_URL = 'https://api.capitalist.net'

‎capitalist/contrib/__init__.py

Whitespace-only changes.

‎capitalist/contrib/django/__init__.py

Whitespace-only changes.

‎capitalist/contrib/django/django_capitalist/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from django.contrib import admin
2+
3+
from .models import Account, Currency
4+
5+
6+
@admin.register(Account)
7+
class AccountAdmin(admin.ModelAdmin):
8+
pass
9+
10+
11+
@admin.register(Currency)
12+
class CurrencyAdmin(admin.ModelAdmin):
13+
pass
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.utils.translation import ugettext_lazy as _
2+
from django.apps import AppConfig
3+
4+
5+
class DjangoCapitalistConfig(AppConfig):
6+
name = 'capitalist.contrib.django.django_capitalist'
7+
verbose_name = _('Capitalist')

‎capitalist/contrib/django/django_capitalist/management/__init__.py

Whitespace-only changes.

‎capitalist/contrib/django/django_capitalist/management/commands/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from django.conf import settings
2+
from django.core.management.base import BaseCommand
3+
4+
from capitalist import Capitalist
5+
from ...models import Account, Currency
6+
7+
8+
class Command(BaseCommand):
9+
def handle(self, *args, **options):
10+
cap = Capitalist(settings.CAPITALIST_LOGIN, settings.CAPITALIST_PASSWORD)
11+
12+
for acc in cap.accounts():
13+
Account.objects.update_or_create(number=acc.number, defaults={
14+
'balance': acc.balance,
15+
'blocked_amount': acc.blocked_amount,
16+
'currency': Currency.objects.get_or_create(code=acc.currency)[0],
17+
'name': acc.name,
18+
'number': acc.number,
19+
})
20+
21+
self.stdout.write(self.style.SUCCESS('Accounts updated successfully'))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Generated by Django 2.1.7 on 2019-03-20 13:21
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
initial = True
10+
11+
dependencies = [
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='Account',
17+
fields=[
18+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('number', models.CharField(max_length=15, unique=True, verbose_name='unique number')),
20+
('name', models.CharField(max_length=255, verbose_name='name')),
21+
('balance', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='balance')),
22+
('blocked_amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='blocked amount')),
23+
],
24+
options={
25+
'verbose_name_plural': 'accounts',
26+
'verbose_name': 'account',
27+
},
28+
),
29+
migrations.CreateModel(
30+
name='Currency',
31+
fields=[
32+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
33+
('code', models.CharField(max_length=3, unique=True, verbose_name='ISO code')),
34+
],
35+
options={
36+
'verbose_name_plural': 'currencies',
37+
'verbose_name': 'currency',
38+
},
39+
),
40+
migrations.AddField(
41+
model_name='account',
42+
name='currency',
43+
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='django_capitalist.Currency', verbose_name='currency'),
44+
),
45+
]

‎capitalist/contrib/django/django_capitalist/migrations/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from django.db import models
2+
from django.utils.translation import gettext_lazy as _
3+
4+
5+
class Currency(models.Model):
6+
code = models.CharField(_('ISO code'), max_length=3, unique=True)
7+
8+
class Meta:
9+
verbose_name = _('currency')
10+
verbose_name_plural = _('currencies')
11+
12+
def __str__(self):
13+
return self.code
14+
15+
16+
# TODO
17+
# class Rate(models.Model):
18+
# source = models.ForeignKey(Currency, models.CASCADE, verbose_name=_('source currency'), related_name='source_rates')
19+
# target = models.ForeignKey(Currency, models.CASCADE, verbose_name=_('target currency'), related_name='target_rates')
20+
21+
22+
class Account(models.Model):
23+
number = models.CharField(_('unique number'), max_length=15, unique=True)
24+
name = models.CharField(_('name'), max_length=255)
25+
balance = models.DecimalField(_('balance'), max_digits=15, decimal_places=2)
26+
blocked_amount = models.DecimalField(_('blocked amount'), max_digits=15, decimal_places=2)
27+
currency = models.ForeignKey(Currency, models.CASCADE, verbose_name=_('currency'))
28+
29+
class Meta:
30+
verbose_name = _('account')
31+
verbose_name_plural = _('accounts')
32+
33+
def __str__(self):
34+
return '{} ({})'.format(self.name, self.number)

‎capitalist/contrib/django/example/app/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from django.contrib import admin
2+
3+
# Register your models here.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.apps import AppConfig
2+
3+
4+
class AppConfig(AppConfig):
5+
name = 'app'

‎capitalist/contrib/django/example/app/migrations/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from django.db import models
2+
3+
# Create your models here.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from django.test import TestCase
2+
3+
# Create your tests here.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from django.shortcuts import render
2+
3+
# Create your views here.

‎capitalist/contrib/django/example/example/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)