From 47ff439abf85ecea69d7f281ca30a00143c8a868 Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Thu, 16 Jan 2020 18:11:35 +0100 Subject: preparations towards the new bank API --- Makefile | 2 +- bin/taler-bank-manage | 3 + run-tests.sh | 2 +- setup.py | 2 +- talerbank/__init__.py | 2 +- .../app/management/commands/add_bank_account.py | 12 +- talerbank/app/management/commands/dump_talerdb.py | 6 +- .../app/management/commands/provide_accounts.py | 7 +- talerbank/app/management/commands/top_up.py | 14 +- talerbank/app/management/commands/wire_transfer.py | 23 +- talerbank/app/middleware.py | 104 ++-- talerbank/app/migrations/0001_initial.py | 94 ++- talerbank/app/models.py | 242 ++++---- talerbank/app/schemas.py | 52 +- talerbank/app/templates/profile_page.html | 46 +- talerbank/app/tests.py | 691 +++++++-------------- talerbank/app/urls.py | 29 +- talerbank/app/views.py | 273 +++----- talerbank/jinja2.py | 20 +- talerbank/settings.py | 110 ++-- 20 files changed, 644 insertions(+), 1090 deletions(-) diff --git a/Makefile b/Makefile index b416c64..21caedf 100644 --- a/Makefile +++ b/Makefile @@ -36,4 +36,4 @@ dist: .PHONY: pretty pretty: - yapf -r -i talerbank/ + black talerbank/ diff --git a/bin/taler-bank-manage b/bin/taler-bank-manage index 4397bae..45047a4 100755 --- a/bin/taler-bank-manage +++ b/bin/taler-bank-manage @@ -65,8 +65,11 @@ def handle_django(args): def handle_serve_http(args): import django django.setup() + print("migrating") call_command('migrate') + print("providing accounts") call_command('provide_accounts') + print("checking") call_command('check') port = args.port TC = TalerConfig.from_file(os.environ.get("TALER_CONFIG_FILE", args.config)) diff --git a/run-tests.sh b/run-tests.sh index 62ccabf..d30faea 100755 --- a/run-tests.sh +++ b/run-tests.sh @@ -3,4 +3,4 @@ # See Bug #5850 for some tests that are currently skipped export TALER_CONFIG_FILE="talerbank/app/testconfigs/bank-check.conf" -./manage.py test --no-input talerbank.app.tests +exec ./manage.py test --no-input talerbank.app.tests "$@" diff --git a/setup.py b/setup.py index 24a7193..18cbbf8 100755 --- a/setup.py +++ b/setup.py @@ -10,7 +10,7 @@ setup(name='talerbank', author_email='stanisci.m@gmail.com, flo@dold.me', license='GPL', packages=find_packages(), - install_requires=["django>=2", + install_requires=["django>=3", "psycopg2", "requests", "uWSGI", diff --git a/talerbank/__init__.py b/talerbank/__init__.py index 6c4125c..80c8cd8 100644 --- a/talerbank/__init__.py +++ b/talerbank/__init__.py @@ -1,4 +1,4 @@ import logging -FMT = '%(asctime)-15s %(module)s %(levelname)s %(message)s' +FMT = "%(asctime)-15s %(module)s %(levelname)s %(message)s" logging.basicConfig(format=FMT, level=logging.WARNING) diff --git a/talerbank/app/management/commands/add_bank_account.py b/talerbank/app/management/commands/add_bank_account.py index 4378e44..4c4aacf 100644 --- a/talerbank/app/management/commands/add_bank_account.py +++ b/talerbank/app/management/commands/add_bank_account.py @@ -40,8 +40,12 @@ class Command(BaseCommand): help = "Add bank accounts." def add_arguments(self, parser): - parser.add_argument("accountname", type=str, help="Login name of the new bank account") - parser.add_argument("--public", action="store_true", help="Make the bank account public") + parser.add_argument( + "accountname", type=str, help="Login name of the new bank account" + ) + parser.add_argument( + "--public", action="store_true", help="Make the bank account public" + ) ## # Django-specific definition to invoke the account creator @@ -56,4 +60,6 @@ class Command(BaseCommand): ), is_public=False, ).save() - print(f"Created new bank account {accountname} (password set to random password)") + print( + f"Created new bank account {accountname} (password set to random password)" + ) diff --git a/talerbank/app/management/commands/dump_talerdb.py b/talerbank/app/management/commands/dump_talerdb.py index 92849eb..bd40064 100644 --- a/talerbank/app/management/commands/dump_talerdb.py +++ b/talerbank/app/management/commands/dump_talerdb.py @@ -37,9 +37,7 @@ def dump_accounts(): print("No accounts created yet..") return for acc in accounts: - print(acc.user.username + \ - " has account number " + \ - str(acc.account_no)) + print(acc.user.username + " has account number " + str(acc.account_no)) except (OperationalError, ProgrammingError): LOGGER.error("Hard database error, does it exist?") sys.exit(1) @@ -56,7 +54,7 @@ def dump_history(): msg.append("-%s, " % item.debit_account.account_no) msg.append(item.amount.stringify(2)) msg.append(" '" + item.subject + "'") - print(''.join(msg)) + print("".join(msg)) except (OperationalError, ProgrammingError): LOGGER.error("Hard database error, does it exist?") sys.exit(1) diff --git a/talerbank/app/management/commands/provide_accounts.py b/talerbank/app/management/commands/provide_accounts.py index ce84835..8e729e3 100644 --- a/talerbank/app/management/commands/provide_accounts.py +++ b/talerbank/app/management/commands/provide_accounts.py @@ -46,10 +46,9 @@ def make_account(username): LOGGER.info("Creating account for '%s'", username) BankAccount( user=User.objects.create_user( - username=username, - password=str(uuid.uuid4()) + username=username, password=str(uuid.uuid4()) ), - is_public=True + is_public=True, ).save() except (OperationalError, ProgrammingError): @@ -58,7 +57,7 @@ def make_account(username): " is not migrated. Try 'taler-bank-manage" " django migrate' in the latter case.", stack_info=False, - exc_info=True + exc_info=True, ) sys.exit(1) diff --git a/talerbank/app/management/commands/top_up.py b/talerbank/app/management/commands/top_up.py index c0da7e7..88cca0a 100644 --- a/talerbank/app/management/commands/top_up.py +++ b/talerbank/app/management/commands/top_up.py @@ -44,12 +44,14 @@ class Command(BaseCommand): "user", type=str, metavar="USERNAME", - help="User that is getting credited with the top-up" + help="User that is getting credited with the top-up", ) parser.add_argument( - "amount", type=str, metavar="AMOUNT", - help="Wire transfer's amount, given in the " \ - "CURRENCY:X.Y form.") + "amount", + type=str, + metavar="AMOUNT", + help="Wire transfer's amount, given in the " "CURRENCY:X.Y form.", + ) ## # Django-specific definition to invoke the account creator @@ -60,9 +62,7 @@ class Command(BaseCommand): user = User.objects.get(username=options["user"]) # Take the money from the bank's account try: - debit_account = BankAccount.objects.get( - account_no=1, - ) + debit_account = BankAccount.objects.get(account_no=1,) except BankAccount.DoesNotExist: LOGGER.error("Debit account (bank's own account) does not exist.") sys.exit(1) diff --git a/talerbank/app/management/commands/wire_transfer.py b/talerbank/app/management/commands/wire_transfer.py index bf6bef1..08372ac 100644 --- a/talerbank/app/management/commands/wire_transfer.py +++ b/talerbank/app/management/commands/wire_transfer.py @@ -46,30 +46,29 @@ class Command(BaseCommand): "user", type=str, metavar="USERNAME", - help="Which user is performing the wire transfer" + help="Which user is performing the wire transfer", ) parser.add_argument( - "password", - type=str, - metavar="PASSWORD", - help="Performing user's password." + "password", type=str, metavar="PASSWORD", help="Performing user's password." ) parser.add_argument( "credit-account", type=int, metavar="CREDIT-ACCOUNT", - help="Which account number will *receive* money." + help="Which account number will *receive* money.", ) parser.add_argument( "subject", type=str, metavar="SUBJECT", - help="SUBJECT will be the wire transfer subject." + help="SUBJECT will be the wire transfer subject.", ) parser.add_argument( - "amount", type=str, metavar="AMOUNT", - help="Wire transfer's amount, given in the " \ - "CURRENCY:X.Y form.") + "amount", + type=str, + metavar="AMOUNT", + help="Wire transfer's amount, given in the " "CURRENCY:X.Y form.", + ) ## # This callable gets invoked when the user invokes the @@ -80,9 +79,7 @@ class Command(BaseCommand): # @param args arguments list -- currently unused. # @param options options given by the user at the command line. def handle(self, *args, **options): - user = authenticate( - username=options["user"], password=options["password"] - ) + user = authenticate(username=options["user"], password=options["password"]) if not user: LOGGER.error("Wrong user/password.") sys.exit(1) diff --git a/talerbank/app/middleware.py b/talerbank/app/middleware.py index eb34b96..e335a1a 100644 --- a/talerbank/app/middleware.py +++ b/talerbank/app/middleware.py @@ -5,21 +5,18 @@ from . import urls from django.http import JsonResponse from django.urls import reverse from django.shortcuts import redirect -from .models import BankAccount, BankTransaction, \ - BankAccountDoesNotExist, BankTransactionDoesNotExist -from .views import \ - (DebitLimitException, SameAccountException, - LoginFailed, RejectNoRightsException, UnhandledException, - set_profile_hint) - -from .schemas import \ - (JSONFieldException, - URLParamValidationError, - InvalidSession) - -from taler.util.amount import \ - (CurrencyMismatch, BadFormatAmount, - NumberTooBig, NegativeNumber) +from .models import BankAccount, BankTransaction +from .views import ( + DebitLimitException, + SameAccountException, + LoginFailed, + UnhandledException, + set_profile_hint, +) + +from .schemas import JSONFieldException, URLParamValidationError, InvalidSession + +from taler.util.amount import CurrencyMismatchError, AmountFormatError LOGGER = logging.getLogger() @@ -56,65 +53,56 @@ class DecompressionMiddleware: return self.get_response(request) -## -# Class holding data needed by the handling logic. + class ExceptionMiddleware: + """ + Middleware for handling exceptions not caught directly + by the application logic. + """ - ## - # Init constructor. - # - # @param self the object itself. - # @param get_response a Django-provided callable that calls - # whatever comes next in the chain: a further middleware - # or the view itself (please refer to the official - # documentation for more details). def __init__(self, get_response): + """ + # Init constructor. + # + # @param self the object itself. + # @param get_response a Django-provided callable that calls + # whatever comes next in the chain: a further middleware + # or the view itself (please refer to the official + # documentation for more details). + """ self.get_response = get_response # Map between endpoints and Web pages to render # after the exception gets managed. self.render = { reverse("profile", urlconf=urls): "profile", - reverse("register", urlconf=urls): "index", + reverse("register", urlconf=urls): "index", reverse("public-accounts", urlconf=urls): "index", } - ## - # This function is transparently invoked by Django when - # a request traverses the chain made of middleware classes - # and the view itself as the last element in the chain. - # - # @param self this class. - # @param request Django-specific request object (of the same - # type that is handed to views). - # @return Django-specific response object. def __call__(self, request): + """ + This function is transparently invoked by Django when + a request traverses the chain made of middleware classes + and the view itself as the last element in the chain. + """ return self.get_response(request) - ## - # Main logic for processing the exception. It checks - # if the exception captured can be managed, and does it - # if so. Otherwise, it lets the native handler operate. - # - # @param self a @a ExceptionMiddleware object. - # @param request Django-specific HTTP request. - # @param exception the exception raised from the bank. def process_exception(self, request, exception): + """ + Main logic for processing the exception. It checks + if the exception captured can be managed, and does it + if so. Otherwise, it lets the native handler operate. + """ LOGGER.error(f"Error: {exception}, while serving {request.get_full_path()}") - if not hasattr(exception, "taler_error_code"): - print("####### Exception without Taler Error Code ########") - traceback.print_exc() - print("###################################################") - exception = UnhandledException() - - render_to = self.render.get(request.path) - - if not render_to: - return JsonResponse({"ec": exception.taler_error_code, - "error": exception.hint}, - status=exception.http_status_code) - set_profile_hint(request, failure=True, success=False, hint=exception.hint) - return redirect(render_to) + if hasattr(exception, "taler_error_code"): + render_to = self.render.get(request.path) -# [1] https://git.taler.net/exchange.git/tree/src/include/taler_error_codes.h + if not render_to: + return JsonResponse( + {"ec": exception.taler_error_code, "error": exception.hint}, + status=exception.http_status_code, + ) + set_profile_hint(request, failure=True, success=False, hint=exception.hint) + return redirect(render_to) diff --git a/talerbank/app/migrations/0001_initial.py b/talerbank/app/migrations/0001_initial.py index e6ca081..91c0e98 100644 --- a/talerbank/app/migrations/0001_initial.py +++ b/talerbank/app/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 2.2.4 on 2019-08-27 18:55 +# Generated by Django 3.0.2 on 2020-01-13 12:57 from django.conf import settings from django.db import migrations, models @@ -17,100 +17,94 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='BankAccount', + name="BankAccount", fields=[ - ('is_public', models.BooleanField(default=False)), - ('debit', models.BooleanField(default=False)), + ("is_public", models.BooleanField(default=False)), + ("account_no", models.AutoField(primary_key=True, serialize=False)), ( - 'account_no', - models.AutoField(primary_key=True, serialize=False) + "balance", + talerbank.app.models.SignedAmountField( + default=talerbank.app.models.get_zero_signed_amount + ), ), ( - 'amount', - talerbank.app.models.AmountField( - default=talerbank.app.models.get_zero_amount - ) - ), - ( - 'user', + "user", models.OneToOneField( on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL - ) + to=settings.AUTH_USER_MODEL, + ), ), ], ), migrations.CreateModel( - name='TalerWithdrawOperation', + name="TalerWithdrawOperation", fields=[ ( - 'withdraw_id', + "withdraw_id", models.UUIDField( default=uuid.uuid4, editable=False, primary_key=True, - serialize=False - ) + serialize=False, + ), ), - ('amount', talerbank.app.models.AmountField(default=False)), - ('selection_done', models.BooleanField(default=False)), - ('withdraw_done', models.BooleanField(default=False)), - ('selected_reserve_pub', models.TextField(null=True)), + ("amount", talerbank.app.models.AmountField(default=False)), + ("selection_done", models.BooleanField(default=False)), + ("withdraw_done", models.BooleanField(default=False)), + ("selected_reserve_pub", models.TextField(null=True)), ( - 'selected_exchange_account', + "selected_exchange_account", models.ForeignKey( null=True, on_delete=django.db.models.deletion.CASCADE, - related_name='selected_exchange_account', - to='app.BankAccount' - ) + related_name="selected_exchange_account", + to="app.BankAccount", + ), ), ( - 'withdraw_account', + "withdraw_account", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name='withdraw_account', - to='app.BankAccount' - ) + related_name="withdraw_account", + to="app.BankAccount", + ), ), ], ), migrations.CreateModel( - name='BankTransaction', + name="BankTransaction", fields=[ ( - 'id', + "id", models.AutoField( auto_created=True, primary_key=True, serialize=False, - verbose_name='ID' - ) + verbose_name="ID", + ), ), - ('amount', talerbank.app.models.AmountField(default=False)), + ("amount", talerbank.app.models.AmountField(default=False)), ( - 'subject', - models.CharField( - default='(no subject given)', max_length=200 - ) + "subject", + models.CharField(default="(no subject given)", max_length=200), ), - ('date', models.DateTimeField(auto_now=True, db_index=True)), - ('cancelled', models.BooleanField(default=False)), + ("date", models.DateTimeField(auto_now=True, db_index=True)), + ("cancelled", models.BooleanField(default=False)), ( - 'credit_account', + "credit_account", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name='credit_account', - to='app.BankAccount' - ) + related_name="credit_account", + to="app.BankAccount", + ), ), ( - 'debit_account', + "debit_account", models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, - related_name='debit_account', - to='app.BankAccount' - ) + related_name="debit_account", + to="app.BankAccount", + ), ), ], ), diff --git a/talerbank/app/models.py b/talerbank/app/models.py index f869ea3..08da111 100644 --- a/talerbank/app/models.py +++ b/talerbank/app/models.py @@ -23,179 +23,185 @@ from typing import Any, Tuple from django.contrib.auth.models import User from django.db import models from django.conf import settings -from django.core.exceptions import \ - ValidationError, \ - ObjectDoesNotExist -from taler.util.amount import Amount, BadFormatAmount, NumberTooBig, CurrencyMismatch +from django.core.exceptions import ValidationError, ObjectDoesNotExist +from taler.util.amount import Amount, SignedAmount, CurrencyMismatchError -class InvalidAmount(Amount): - def __init__(self, currency): - super(InvalidAmount, self - ).__init__(currency, value=float('nan'), fraction=float('nan')) - def stringify(self, ndigits, pretty): - return "Invalid Amount, please report" +def get_zero_amount() -> Amount: + """ + Helper function that instantiates a zero-valued Amount + object in the currency that the bank runs on. + """ + return Amount(settings.TALER_CURRENCY, 0, 0) - def dump(self): - return "Invalid Amount, please report" +def get_zero_signed_amount() -> SignedAmount: + """ + Helper function that instantiates a zero-valued SignedAmount + object in the currency that the bank runs on. + """ + return SignedAmount(True, get_zero_amount()) -## -# Helper function that instantiates a zero-valued @a Amount -# object. -def get_zero_amount() -> Amount: - return Amount(settings.TALER_CURRENCY) + +class SignedAmountField(models.Field): + """Custom implementation of the SignedAmount class as a database type.""" + + description = "Signed amount object in Taler style" + + def db_type(self, connection: Any) -> str: + """ + Return the database type of the serialized amount. + """ + return "varchar" + + def get_prep_value(self, value: SignedAmount) -> str: + """ + Stringifies the Amount object to feed the DB connector. + """ + c = value.amount.currency + if settings.TALER_CURRENCY != c: + raise CurrencyMismatchError(settings.TALER_CURRENCY, c) + return value.stringify() + + @staticmethod + def from_db_value(value: str, *args) -> Amount: + """ + Parse the stringified Amount back to Python. + + Parameters + ---------- + value : str + Serialized amount coming from the database. + (String in the usual CURRENCY:X.Y format) + args : any + Unused + """ + del args # pacify PEP checkers + return SignedAmount.parse(value) + + def to_python(self, value: Any) -> Amount: + """ + Parse the stringified Amount back to Python. FIXME: + why this serializer consider _more_ cases respect to the + one above ('from_db_value')? + + Parameters + ---------- + value: serialized amount coming from the database + + """ + + if isinstance(value, SignedAmount): + return value + try: + return SignedAmount.parse(value) + except BadFormatAmount: + raise ValidationError( + "Invalid input for a signed amount string: %s" % value + ) -## -# Custom implementation of the @a Amount class as a database type. class AmountField(models.Field): - description = 'Amount object in Taler style' - - ## - # Return the database type of the serialized amount. - # - # @param self the object itself. - # @param connection the database connection. - # @return type of the serialized amount: varchar. + """Custom implementation of the Amount class as a database type.""" + + description = "Amount object in Taler style" + def db_type(self, connection: Any) -> str: + """ + Return the database type of the serialized amount. + """ return "varchar" - ## - # Stringifies the Amount object to feed the DB connector. - # - # @param self the object itself. - # @para value the @a Amount object to be serialized. def get_prep_value(self, value: Amount) -> str: - if not value: - return "%s:0.0" % settings.TALER_CURRENCY + """ + Stringifies the Amount object to feed the DB connector. + """ if settings.TALER_CURRENCY != value.currency: - raise CurrencyMismatch(settings.TALER_CURRENCY, value.currency) - return value.stringify(settings.TALER_DIGITS) - - ## - # Parse the stringified Amount back to Python. - # - # @param value serialized amount coming from the database. - # (It is just a string in the usual CURRENCY:X.Y form) - # @param args currently unused. - # @return the @a Amount object. + raise CurrencyMismatchError(settings.TALER_CURRENCY, value.currency) + return value.stringify() + @staticmethod def from_db_value(value: str, *args) -> Amount: + """ + Parse the stringified Amount back to Python. + + Parameters + ---------- + value : str + Serialized amount coming from the database. + (String in the usual CURRENCY:X.Y format) + args : any + Unused + """ del args # pacify PEP checkers - if value is None: - return Amount.parse(settings.TALER_CURRENCY) - try: - return Amount.parse(value) - except NumberTooBig: - # Keep the currency right to avoid causing - # exceptions if some operation is attempted - # against this invalid amount. NOTE that the - # value is defined as NaN, so no actual/useful - # amount will ever be generated using this one. - # And more, the NaN value will make it easier - # to scan the database to find these faulty - # amounts. - # We also decide to not raise exception here - # because they would propagate in too many places - # in the code, and it would be too verbose to - # just try-cactch any possible exception situation. - return InvalidAmount(settings.TALER_CURRENCY) - - ## - # Parse the stringified Amount back to Python. FIXME: - # why this serializer consider _more_ cases respect to the - # one above ('from_db_value')? - # - # @param value serialized amount coming from the database. - # (It is just a string in the usual CURRENCY:X.Y form) - # @param args currently unused. - # @return the @a Amount object. + return Amount.parse(value) + def to_python(self, value: Any) -> Amount: + """ + Parse the stringified Amount back to Python. FIXME: + why this serializer consider _more_ cases respect to the + one above ('from_db_value')? + + Parameters + ---------- + value: serialized amount coming from the database + + """ + if isinstance(value, Amount): return value try: - if value is None: - return Amount.parse(settings.TALER_CURRENCY) return Amount.parse(value) except BadFormatAmount: raise ValidationError("Invalid input for an amount string: %s" % value) -class BankAccountDoesNotExist(Exception): - def __init__(self, msg): - super(BankAccountDoesNotExist, self).__init__(msg) - self.hint = msg - self.http_status_code = 404 - self.taler_error_code = 5110 - -class BankTransactionDoesNotExist(Exception): - def __init__(self, msg): - super(BankTransactionDoesNotExist, self).__init__(msg) - self.hint = msg - self.http_status_code = 404 - self.taler_error_code = 5111 def join_dict(**inputDict): - return ", ".join(['%s==%s' % (key, value) for (key, value) in inputDict.items()]) + return ", ".join(["%s==%s" % (key, value) for (key, value) in inputDict.items()]) -class CustomManager(models.Manager): - def __init__(self): - super(CustomManager, self).__init__() - - def get_queryset(self): - return models.QuerySet(self.model, using=self._db) - - def get(self, *args, **kwargs): - try: - return super(CustomManager, self).get(*args, **kwargs) - except BankAccount.DoesNotExist: - raise BankAccountDoesNotExist(f"Bank account not found for {join_dict(**kwargs)}") - except BankTransaction.DoesNotExist: - raise BankTransactionDoesNotExist(f"Bank transaction not found for {join_dict(**kwargs)}") - -## -# The class representing a bank account. class BankAccount(models.Model): + """ + The class representing a bank account. + """ + is_public = models.BooleanField(default=False) - debit = models.BooleanField(default=False) account_no = models.AutoField(primary_key=True) user = models.OneToOneField(User, on_delete=models.CASCADE) - amount = AmountField(default=get_zero_amount) - objects = CustomManager() + balance = SignedAmountField(default=get_zero_signed_amount) + -## -# The class representing a bank transaction. class BankTransaction(models.Model): + """ + The class representing a bank transaction. + """ + amount = AmountField(default=False) debit_account = models.ForeignKey( BankAccount, on_delete=models.CASCADE, db_index=True, - related_name="debit_account" + related_name="debit_account", ) credit_account = models.ForeignKey( BankAccount, on_delete=models.CASCADE, db_index=True, - related_name="credit_account" + related_name="credit_account", ) subject = models.CharField(default="(no subject given)", max_length=200) date = models.DateTimeField(auto_now=True, db_index=True) cancelled = models.BooleanField(default=False) - objects = CustomManager() class TalerWithdrawOperation(models.Model): - withdraw_id = models.UUIDField( - primary_key=True, default=uuid.uuid4, editable=False - ) + withdraw_id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) amount = AmountField(default=False) withdraw_account = models.ForeignKey( BankAccount, on_delete=models.CASCADE, db_index=True, - related_name="withdraw_account" + related_name="withdraw_account", ) selection_done = models.BooleanField(default=False) withdraw_done = models.BooleanField(default=False) @@ -203,6 +209,6 @@ class TalerWithdrawOperation(models.Model): BankAccount, null=True, on_delete=models.CASCADE, - related_name="selected_exchange_account" + related_name="selected_exchange_account", ) selected_reserve_pub = models.TextField(null=True) diff --git a/talerbank/app/schemas.py b/talerbank/app/schemas.py index 2825241..91d6c0a 100644 --- a/talerbank/app/schemas.py +++ b/talerbank/app/schemas.py @@ -32,7 +32,7 @@ from urllib.parse import urlparse # can handle (because of the wallet). # FIXME: also defined in views.py. Need a common.py to contain # such definitions ? -UINT64_MAX = (2**64) - 1 +UINT64_MAX = (2 ** 64) - 1 ## # Pattern for amounts, plain RegEx. @@ -54,11 +54,13 @@ class InvalidSession(ValueError): self.http_status_code = http_status_code super().__init__() + class InternalServerError(Exception): def __init__(self, hint): self.hint = hint self.http_status_code = 500 - self.taler_error_code = 1011 # TALER_EC_INTERNAL_LOGIC_ERROR + self.taler_error_code = 1011 # TALER_EC_INTERNAL_LOGIC_ERROR + ## # Exception class to be raised when a JSON @@ -82,6 +84,7 @@ class JSONFieldException(ValueError): self.http_status_code = http_status_code self.taler_error_code = 5106 + ## # Exception class to be raised when at least one expected URL # parameter is either not found or malformed. @@ -103,9 +106,7 @@ class URLParamValidationError(ValueError): class AuthForm(forms.Form): type = forms.CharField( validators=[ - RegexValidator( - "^basic$", message="Only 'basic' method provided for now" - ) + RegexValidator("^basic$", message="Only 'basic' method provided for now") ] ) @@ -129,36 +130,24 @@ class AuthField(forms.Field): ## # Common logic to inherit from all the other validators -class BankValidator(): +class BankValidator: def __init__(self, validator, data): self.validation_result = validator(data) if not self.validation_result.is_valid(): raise JSONFieldException(self.validation_result.errors, 400) def get(self, name, default=None): - ret = self.validation_result.cleaned_data.get(name) + ret = self.validation_result.cleaned_data.get(name) if not ret: return default return ret -class RejectData(BankValidator): - def __init__(self, data): - super(RejectData, self).__init__(self.InnerValidator, data) - - class InnerValidator(forms.Form): - auth = AuthField() - # FIXME: adjust min/max values. - row_id = forms.IntegerField() - account_number = forms.IntegerField() - - class AddIncomingData(BankValidator): def __init__(self, data): super(AddIncomingData, self).__init__(self.InnerValidator, data) class InnerValidator(forms.Form): - auth = AuthField() amount = forms.CharField( validators=[ RegexValidator( @@ -174,20 +163,12 @@ class AddIncomingData(BankValidator): ## # Subset of /history and /history-range input. class HistoryParamsBase(forms.Form): - auth = forms.CharField( - validators=[ - RegexValidator("^basic$", message="Only 'basic' is allowed") - ] - ) - cancelled = forms.CharField( required=False, empty_value="show", validators=[ - RegexValidator( - "^(omit|show)$", message="Only 'omit' or 'show' are valid" - ) - ] + RegexValidator("^(omit|show)$", message="Only 'omit' or 'show' are valid") + ], ) ordering = forms.CharField( @@ -196,16 +177,16 @@ class HistoryParamsBase(forms.Form): validators=[ RegexValidator( "^(ascending|descending)$", - message="Only 'ascending' or 'descending' are valid" + message="Only 'ascending' or 'descending' are valid", ) - ] + ], ) direction = forms.CharField( validators=[ RegexValidator( "^(debit|credit|both|cancel\+|cancel-)$", - message="Only: debit/credit/both/cancel+/cancel-" + message="Only: debit/credit/both/cancel+/cancel-", ) ] ) @@ -217,7 +198,7 @@ class HistoryParamsBase(forms.Form): class HistoryParams(BankValidator): def __init__(self, data): super(HistoryParams, self).__init__(self.InnerValidator, data) - + class InnerValidator(HistoryParamsBase): # FIXME: adjust min/max values. delta = forms.IntegerField() @@ -225,7 +206,6 @@ class HistoryParams(BankValidator): class HistoryRangeParams(BankValidator): - def __init__(self, data): super(HistoryRangeParams, self).__init__(self.InnerValidator, data) @@ -256,12 +236,10 @@ class PaytoField(forms.Field): class WithdrawHeadless(BankValidator): - def __init__(self, data): super(WithdrawHeadless, self).__init__(self.InnerValidator, data) class InnerValidator(forms.Form): - auth = AuthField() amount = forms.CharField( validators=[ RegexValidator( @@ -272,8 +250,8 @@ class WithdrawHeadless(BankValidator): reserve_pub = forms.CharField(required=True) exchange_wire_details = PaytoField(required=False) -class WithdrawHeadlessUri(BankValidator): +class WithdrawHeadlessUri(BankValidator): def __init__(self, data): super(WithdrawHeadlessUri, self).__init__(self.InnerValidator, data) diff --git a/talerbank/app/templates/profile_page.html b/talerbank/app/templates/profile_page.html index bb6ce49..cea85ce 100644 --- a/talerbank/app/templates/profile_page.html +++ b/talerbank/app/templates/profile_page.html @@ -15,6 +15,7 @@ TALER; see the file COPYING. If not, see @author Marcello Stanisci + @author Florian Dold #} {% block headermsg %} @@ -25,13 +26,7 @@ {% endblock headermsg %} {% block content %}
@@ -73,47 +68,12 @@ class="pure-button pure-button-primary" type="submit" value="Start withdrawal" /> - -

Wire transfer

-
- - - -
- -
- - {{ wt_form.amount }} -
- -
- - {{ wt_form.receiver }} -
- -
- - {{ wt_form.subject }} -
- -
- - - -

-

Transaction history

+

Transactions for {{ name }}

{% if history %} diff --git a/talerbank/app/tests.py b/talerbank/app/tests.py index 87d7283..61aec9e 100644 --- a/talerbank/app/tests.py +++ b/talerbank/app/tests.py @@ -31,17 +31,23 @@ from django.urls import reverse from django.conf import settings from django.contrib.auth.models import User from mock import patch, MagicMock -from .models import BankAccount, BankTransaction, \ - BankAccountDoesNotExist, BankTransactionDoesNotExist +from .models import BankAccount, BankTransaction from . import urls from .views import wire_transfer -from taler.util.amount import Amount, CurrencyMismatch, BadFormatAmount +from taler.util.amount import ( + Amount, + SignedAmount, + CurrencyMismatchError, + AmountFormatError, +) LOGGER = logging.getLogger() -LOGGER.setLevel(logging.INFO) +LOGGER.setLevel(logging.DEBUG) -logging.disable(logging.CRITICAL) +# logging.disable(logging.CRITICAL) # reenable: logging.disable(logging.NOTSET) +logging.disable(logging.NOTSET) + def make_auth_line(username, password): credentials = "%s:%s" % (username, password) @@ -49,14 +55,13 @@ def make_auth_line(username, password): header_line = "Basic %s" % b64enc.decode() return header_line + def clear_db(): User.objects.all().delete() BankAccount.objects.all().delete() BankTransaction.objects.all().delete() with connection.cursor() as cursor: - cursor.execute( - "ALTER SEQUENCE app_bankaccount_account_no_seq" \ - " RESTART") + cursor.execute("ALTER SEQUENCE app_bankaccount_account_no_seq" " RESTART") cursor.execute("ALTER SEQUENCE app_banktransaction_id_seq RESTART") @@ -64,13 +69,9 @@ def clear_db(): # to some endpoint that needs to authenticate the # user. class MalformedLoginTestCase(TestCase): - def test_malformed_login(self): - self.client.generic( - "POST", - reverse("add-incoming", urlconf=urls), - "malformed" - ) + self.client.generic("POST", reverse("add-incoming", urlconf=urls), "malformed") + class PublicAccountsTestCase(TestCase): def setUp(self): @@ -96,22 +97,20 @@ class WithdrawTestCase(TestCase): user=User.objects.create_user( username="test_user", password="test_password" ), - account_no=100 + account_no=100, ) self.user_bank_account.save() self.exchange_bank_account = BankAccount( - user=User.objects.create_user( - username="test_exchange", password="" - ), - account_no=99 + user=User.objects.create_user(username="test_exchange", password=""), + account_no=99, ) self.exchange_bank_account.save() self.client = Client() - @patch('talerbank.app.views.wire_transfer') - @patch('hashlib.new') - @patch('time.time') + @patch("talerbank.app.views.wire_transfer") + @patch("hashlib.new") + @patch("time.time") @unittest.skip("skip outdated test case") def test_withdraw(self, mocked_time, mocked_hashlib, mocked_wire_transfer): amount = Amount(settings.TALER_CURRENCY, 0, 1) @@ -121,13 +120,11 @@ class WithdrawTestCase(TestCase): "amount_currency": amount.currency, "reserve_pub": "UVZ789", "exchange": "https://exchange.example.com/", - "exchange_wire_details": "payto://x-taler-bank/bank.example/99" + "exchange_wire_details": "payto://x-taler-bank/bank.example/99", } self.client.login(username="test_user", password="test_password") - response = self.client.get( - reverse("pin-question", urlconf=urls), params - ) + response = self.client.get(reverse("pin-question", urlconf=urls), params) self.assertEqual(response.status_code, 200) # We mock hashlib in order to fake the CAPTCHA. hasher = MagicMock() @@ -143,10 +140,11 @@ class WithdrawTestCase(TestCase): args, kwargs = mocked_wire_transfer.call_args del kwargs self.assertTrue( - args[0].dump() == amount.dump() \ - and self.user_bank_account in args \ - and "UVZ789" in args \ - and self.exchange_bank_account in args) + args[0].dump() == amount.dump() + and self.user_bank_account in args + and "UVZ789" in args + and self.exchange_bank_account in args + ) def tearDown(self): clear_db() @@ -155,10 +153,10 @@ class WithdrawTestCase(TestCase): class InternalWireTransferTestCase(TestCase): def setUp(self): BankAccount( - user=User.objects.create_user(username='give_money', password="gm") + user=User.objects.create_user(username="give_money", password="gm") ).save() self.take_money = BankAccount( - user=User.objects.create_user(username='take_money'), account_no=4 + user=User.objects.create_user(username="take_money"), account_no=4 ) self.take_money.save() @@ -169,25 +167,25 @@ class InternalWireTransferTestCase(TestCase): client = Client() client.login(username="give_money", password="gm") response = client.post( - reverse("profile", urlconf=urls), { + reverse("profile", urlconf=urls), + { "amount": 3.0, "receiver": self.take_money.account_no, - "subject": "charity" - } + "subject": "charity", + }, ) take_money = BankAccount.objects.get(account_no=4) - self.assertEqual( - 0, - Amount.cmp(Amount(settings.TALER_CURRENCY, 3), take_money.amount) - ) + r = SignedAmount.parse(f"{settings.TALER_CURRENCY}:3.0") + self.assertEqual(take_money.balance, r) self.assertEqual(302, response.status_code) class RegisterTestCase(TestCase): """User registration""" + def setUp(self): clear_db() - BankAccount(user=User.objects.create_user(username='Bank')).save() + BankAccount(user=User.objects.create_user(username="Bank")).save() def tearDown(self): clear_db() @@ -195,11 +193,9 @@ class RegisterTestCase(TestCase): def test_register(self): client = Client() response = client.post( - reverse("register", urlconf=urls), { - "username": "test_register", - "password": "test_register" - }, - follow=True + reverse("register", urlconf=urls), + {"username": "test_register", "password": "test_register"}, + follow=True, ) self.assertIn(("/profile", 302), response.redirect_chain) # this assertion tests "/profile""s view @@ -210,10 +206,8 @@ class RegisterTestCase(TestCase): # Normal case. response = client.post( - reverse("register-headless", urlconf=urls), { - "username": "test_register_headless", - "password": "password*+#@" - } + reverse("register-headless", urlconf=urls), + {"username": "test_register_headless", "password": "password*+#@"}, ) self.assertEqual(200, response.status_code) @@ -226,29 +220,27 @@ class RegisterTestCase(TestCase): # Try registering unavailable username. response = client.post( - reverse("register-headless", urlconf=urls), { - "username": "test_register_headless", - "password": "password" - } + reverse("register-headless", urlconf=urls), + {"username": "test_register_headless", "password": "password"}, ) self.assertEqual(409, response.status_code) # NOTE: Django 2.2.2 allows ANY character! Is this normal? response = client.post( - reverse("register-headless", urlconf=urls), { - "username": "'''+++;;;'''", - "password": "password2" - } + reverse("register-headless", urlconf=urls), + {"username": "'''+++;;;'''", "password": "password2"}, ) self.assertEqual(200, response.status_code) class LoginTestCase(TestCase): """User login""" + def setUp(self): BankAccount( - user=User.objects. - create_user(username="test_user", password="test_password") + user=User.objects.create_user( + username="test_user", password="test_password" + ) ).save() self.client = Client() @@ -265,178 +257,112 @@ class LoginTestCase(TestCase): def test_failing_login(self): response = self.client.get( - reverse("history", urlconf=urls), {"auth": "basic"}, **{ - "HTTP_AUTHORIZATION": make_auth_line("Wrong", "Credentials") - } + reverse("history", urlconf=urls), + {"auth": "basic"}, + HTTP_AUTHORIZATION=make_auth_line("Wrong", "Credentials"), ) data = response.content.decode("utf-8") self.assertEqual(401, response.status_code) -class AmountTestCase(TestCase): - def test_cmp(self): - amount1 = Amount("X", 1) - _amount1 = Amount("X", 1) - amount2 = Amount("X", 2) - - self.assertEqual(-1, Amount.cmp(amount1, amount2)) - self.assertEqual(1, Amount.cmp(amount2, amount1)) - self.assertEqual(0, Amount.cmp(amount1, _amount1)) - - # Trying to compare amount of different currencies - def test_cmp_diff_curr(self): - amount1 = Amount("X", 1) - amount2 = Amount("Y", 2) - with self.assertRaises(CurrencyMismatch): - Amount.cmp(amount1, amount2) - - -class RejectTestCase(TestCase): - def setUp(self): - BankAccount( - user=User.objects. - create_user(username="rejected_user", password="rejected_password") - ).save() - BankAccount( - user=User.objects.create_user( - username="rejecting_user", password="rejecting_password" - ) - ).save() - - def tearDown(self): - clear_db() - - def test_reject(self): - client = Client() - rejecting = User.objects.get(username="rejecting_user") - data = '{"auth": {"type": "basic"}, \ - "credit_account": %d, \ - "subject": "TESTWTID", \ - "exchange_url": "https://exchange.test", \ - "amount": "%s:5.0"}' \ - % (rejecting.bankaccount.account_no, - settings.TALER_CURRENCY) - response = client.post( - reverse("add-incoming", urlconf=urls), - data=data, - content_type="application/json", - follow=True, - **{ - "HTTP_AUTHORIZATION": make_auth_line("rejected_user", "rejected_password"), - } - ) - self.assertEqual(response.status_code, 200) - data = response.content.decode("utf-8") - jdata = json.loads(data) - rejected = User.objects.get(username="rejected_user") - response = client.put( - reverse("reject", urlconf=urls), - data='{"row_id": %d, \ - "auth": {"type": "basic"}, \ - "account_number": %d}' \ - % (jdata["row_id"], - rejected.bankaccount.account_no), - content_type="application/json", - **{ - "HTTP_AUTHORIZATION": make_auth_line("rejecting_user", "rejecting_password"), - }) - self.assertEqual(response.status_code, 204) - - class WithdrawHeadlessTestCase(TestCase): def setUp(self): BankAccount( user=User.objects.create_user( username="headless_wallet", password="headless_password" ), - amount=Amount(settings.TALER_CURRENCY, 10) + balance=SignedAmount(True, Amount(settings.TALER_CURRENCY, 10, 0)), ).save() # Gets account #2, in line with config. BankAccount( user=User.objects.create_user( username="normal_exchange", password="normal_password" ), - account_no=2 + account_no=2, ).save() def test_withdraw_headless(self): client = Client() # Use default exchange. - data = '{"auth": {"type": "basic"}, \ + data = ( + '{"auth": {"type": "basic"}, \ "reserve_pub": "RESERVEPUB", \ - "amount": "%s:10"}' % settings.TALER_CURRENCY + "amount": "%s:10"}' + % settings.TALER_CURRENCY + ) response = client.post( reverse("withdraw-headless", urlconf=urls), data=data, content_type="application/json", follow=True, - **{ - "HTTP_AUTHORIZATION": make_auth_line("headless_wallet", "headless_password") - - } + HTTP_AUTHORIZATION=make_auth_line("headless_wallet", "headless_password"), ) self.assertEqual(200, response.status_code) # Try withdrawing more than owning. - data = '{"auth": {"type": "basic"}, \ + data = ( + '{"auth": {"type": "basic"}, \ "reserve_pub": "RESERVEPUB", \ - "amount": "%s:100"}' % settings.TALER_CURRENCY + "amount": "%s:100"}' + % settings.TALER_CURRENCY + ) response = client.post( reverse("withdraw-headless", urlconf=urls), data=data, content_type="application/json", follow=True, - **{ - "HTTP_AUTHORIZATION": make_auth_line("headless_wallet", "headless_password") - } + HTTP_AUTHORIZATION=make_auth_line("headless_wallet", "headless_password"), ) self.assertEqual(406, response.status_code) # Try withdrawing giving exchange field. - data = '{"auth": {"type": "basic"}, \ + data = ( + '{"auth": {"type": "basic"}, \ "exchange_wire_details": "payto://x-taler-bank/bank.example.com/2", \ "reserve_pub": "RESERVEPUB", \ - "amount": "%s:0.4"}' % settings.TALER_CURRENCY + "amount": "%s:0.4"}' + % settings.TALER_CURRENCY + ) response = client.post( reverse("withdraw-headless", urlconf=urls), data=data, content_type="application/json", follow=True, - **{ - "HTTP_AUTHORIZATION": make_auth_line("headless_wallet", "headless_password") - } + HTTP_AUTHORIZATION=make_auth_line("headless_wallet", "headless_password"), ) self.assertEqual(200, response.status_code) # Try withdrawing giving non-existent recipient. - data = '{"auth": {"type": "basic"}, \ + data = ( + '{"auth": {"type": "basic"}, \ "exchange_wire_details": "payto://x-taler-bank/bank.example.com/2222", \ "reserve_pub": "RESERVEPUB", \ - "amount": "%s:0.4"}' % settings.TALER_CURRENCY + "amount": "%s:0.4"}' + % settings.TALER_CURRENCY + ) response = client.post( reverse("withdraw-headless", urlconf=urls), data=data, content_type="application/json", follow=True, - **{ - "HTTP_AUTHORIZATION": make_auth_line("headless_wallet", "headless_password") - } + HTTP_AUTHORIZATION=make_auth_line("headless_wallet", "headless_password"), ) self.assertEqual(404, response.status_code) # Try withdrawing giving invalid JSON. - data = '{"auth": {"type": "basic"}, \ + data = ( + '{"auth": {"type": "basic"}, \ "XXX": "YYY", \ - "amount": "%s:0.4"}' % settings.TALER_CURRENCY + "amount": "%s:0.4"}' + % settings.TALER_CURRENCY + ) response = client.post( reverse("withdraw-headless", urlconf=urls), data=data, content_type="application/json", follow=True, - **{ - "HTTP_AUTHORIZATION": make_auth_line("headless_wallet", "headless_password") - } + HTTP_AUTHORIZATION=make_auth_line("headless_wallet", "headless_password"), ) self.assertEqual(400, response.status_code) @@ -446,14 +372,17 @@ class WithdrawHeadlessTestCase(TestCase): class AddIncomingTestCase(TestCase): """Test money transfer's API""" + def setUp(self): BankAccount( - user=User.objects. - create_user(username="bank_user", password="bank_password") + user=User.objects.create_user( + username="bank_user", password="bank_password" + ) ).save() BankAccount( - user=User.objects. - create_user(username="user_user", password="user_password") + user=User.objects.create_user( + username="user_user", password="user_password" + ) ).save() def tearDown(self): @@ -461,20 +390,20 @@ class AddIncomingTestCase(TestCase): def test_add_incoming(self): client = Client() - data = '{"auth": {"type": "basic"}, \ + data = ( + '{"auth": {"type": "basic"}, \ "credit_account": 1, \ "subject": "TESTWTID", \ "exchange_url": "https://exchange.test", \ - "amount": "%s:1.0"}' \ - % settings.TALER_CURRENCY + "amount": "%s:1.0"}' + % settings.TALER_CURRENCY + ) response = client.post( reverse("add-incoming", urlconf=urls), data=data, content_type="application/json", follow=True, - **{ - "HTTP_AUTHORIZATION": make_auth_line("user_user", "user_password") - } + HTTP_AUTHORIZATION=make_auth_line("user_user", "user_password"), ) self.assertEqual(200, response.status_code) @@ -485,10 +414,8 @@ class AddIncomingTestCase(TestCase): data=zdata, content_type="application/json", follow=True, - **{ - "HTTP_AUTHORIZATION": make_auth_line("user_user", "user_password"), - "HTTP_CONTENT_ENCODING": "deflate" - } + HTTP_AUTHORIZATION=make_auth_line("user_user", "user_password"), + HTTP_CONTENT_ENCODING="deflate", ) self.assertEqual(200, response.status_code) @@ -503,360 +430,181 @@ class AddIncomingTestCase(TestCase): data=data, content_type="application/json", follow=True, - **{ - "HTTP_AUTHORIZATION": make_auth_line("user_user", "user_password") - } + HTTP_AUTHORIZATION=make_auth_line("user_user", "user_password"), ) # note: a bad currency request gets 400. - self.assertRaises(CurrencyMismatch) - self.assertEqual(406, response.status_code) + self.assertEqual(400, response.status_code) LOGGER.info(response.content.decode("utf-8")) # Try to go debit - data = '{"auth": {"type": "basic"}, \ + data = ( + '{"auth": {"type": "basic"}, \ "credit_account": 1, \ "subject": "TESTWTID", \ "exchange_url": "https://exchange.test", \ - "amount": "%s:50.1"}' % settings.TALER_CURRENCY + "amount": "%s:50.1"}' + % settings.TALER_CURRENCY + ) response = client.post( reverse("add-incoming", urlconf=urls), data=data, content_type="application/json", follow=True, - **{ - "HTTP_AUTHORIZATION": make_auth_line("user_user", "user_password") - } + HTTP_AUTHORIZATION=make_auth_line("user_user", "user_password"), ) self.assertEqual(406, response.status_code) # Try use a non-existent recipient. - data = '{"auth": {"type": "basic"}, \ + data = ( + '{"auth": {"type": "basic"}, \ "credit_account": 1987, \ "subject": "TESTWTID", \ "exchange_url": "https://exchange.test", \ - "amount": "%s:1"}' % settings.TALER_CURRENCY + "amount": "%s:1"}' + % settings.TALER_CURRENCY + ) response = client.post( reverse("add-incoming", urlconf=urls), data=data, content_type="application/json", follow=True, - **{ - "HTTP_AUTHORIZATION": make_auth_line("user_user", "user_password") - } + HTTP_AUTHORIZATION=make_auth_line("user_user", "user_password"), ) self.assertEqual(404, response.status_code) -class HistoryContext: - def __init__(self, expected_resp, **kwargs): - self.expected_resp = expected_resp - self.urlargs = kwargs - self.urlargs.update({"auth": "basic"}) - - def dump(self): - return self.urlargs - class CustomDoesNotExistTestCase(TestCase): - def test_bankaccount_doesnotexist(self): - with self.assertRaises(BankAccountDoesNotExist): + with self.assertRaises(BankAccount.DoesNotExist): BankAccount.objects.get(account_no=1000) - with self.assertRaises(BankTransactionDoesNotExist): + with self.assertRaises(BankTransaction.DoesNotExist): BankTransaction.objects.get(subject="1000") + class HistoryTestCase(TestCase): def setUp(self): clear_db() debit_account = BankAccount( - user=User.objects.create_user(username='User', password="Password"), - amount=Amount(settings.TALER_CURRENCY, 100) + user=User.objects.create_user(username="User", password="Password"), + balance=SignedAmount(True, Amount(settings.TALER_CURRENCY, 100, 0)), ) debit_account.save() credit_account = BankAccount( - user=User.objects. - create_user(username='User0', password="Password0") + user=User.objects.create_user(username="User0", password="Password0") ) credit_account.save() for subject in ("a", "b", "c", "d", "e", "f", "g", "h", "i"): wire_transfer( - Amount(settings.TALER_CURRENCY, 1), debit_account, - credit_account, subject + Amount(settings.TALER_CURRENCY, 1, 0), + debit_account, + credit_account, + subject, ) - # reject transaction 'i'. - trans_i = BankTransaction.objects.get(subject="i") - self.client = Client() - self.client.post( - reverse("reject", urlconf=urls), - data='{"auth": {"type": "basic"}, \ - "row_id": %d, \ - "account_number": 44}' % trans_i.id, # Ignored - content_type="application/json", - follow=True, - **{ - "HTTP_AUTHORIZATION": make_auth_line("User0", "Password0") - } - ) def tearDown(self): clear_db() - def assert_result(self, response, ctx): - - data = response.content.decode("utf-8") - try: - # FIXME, not always data is found this way. - data = json.loads(data)["data"][0] - except (json.JSONDecodeError, KeyError): - data = {} - self.assertEqual( - ctx.expected_resp.get("status"), - response.status_code, - "Failing request: %s?%s => raw body: %s" % \ - (response.request["PATH_INFO"], - unquote(response.request["QUERY_STRING"]), - response.content.decode("utf-8"))) - - # extract expected data from response - expected_data = {} - response_data = {} - for key, value in ctx.expected_resp.get("fields", []): - response_data.update({key: data.get(key)}) - expected_data.update({key: value}) - - self.assertEqual(expected_data, response_data) - - def test_history_range(self): - now = int(time.time()) - - for ctx in ( - - # Expect empty results, range too ancient. - HistoryContext( - expected_resp={"status": 204}, start=1, end=2, direction="both" - ), - - # Expect empty results, range too ahead. - HistoryContext( - expected_resp={"status": 200}, - start=now + 40, - end=now + 50, - direction="both" - ), - - # Expect non empty results. - HistoryContext( - expected_resp={"status": 200}, - start=now - 30, - end=now + 30, - direction="both" - ) - ): - - response = self.client.get( - reverse("history-range", urlconf=urls), ctx.urlargs, **{ - "HTTP_AUTHORIZATION": make_auth_line("User", "Password") - } - ) - - self.assert_result(response, ctx) - def test_history(self): - for ctx in ( - HistoryContext( - expected_resp={"status": 200}, delta="-4", direction="both" - ), - HistoryContext( - expected_resp={ - "fields": [("row_id", 9)], - "status": 200 - }, - delta="+1", - start="5", - direction="both" - ), - HistoryContext( - expected_resp={ - "fields": [("wt_subject", "c")], - "status": 200 - }, - delta="1", - start=2, - direction="both", - ordering="ascending" - ), - HistoryContext( - expected_resp={ - "fields": [("wt_subject", "a")], - "status": 200 - }, - delta="-1", - start=2, - direction="both" - ), - HistoryContext( - expected_resp={"status": 204}, - delta="1", - start="11", - direction="both" - ), - HistoryContext( - expected_resp={ - "status": 200, - "fields": [("wt_subject", "i"), ("sign", "cancel-")] - }, - start=8, - delta="+1", - direction="cancel-" - ), - HistoryContext( - expected_resp={"status": 204}, - start=8, - delta="+1", - direction="cancel-", - cancelled="omit" - ), - HistoryContext( - expected_resp={"status": 204}, - start=8, - delta="-1", - direction="cancel-" - ), - HistoryContext( - expected_resp={"status": 204}, delta="+1", direction="cancel+" - ), - HistoryContext( - expected_resp={"status": 200}, delta="-1", direction="debit" - ) - ): + def histquery(**urlargs): response = self.client.get( - reverse("history", urlconf=urls), ctx.urlargs, **{ - "HTTP_AUTHORIZATION": make_auth_line("User", "Password") - } + reverse("history", urlconf=urls), + urlargs, + HTTP_AUTHORIZATION=make_auth_line("User", "Password"), ) - self.assert_result(response, ctx) - - -class DBAmountSubtraction(TestCase): - def setUp(self): - BankAccount( - user=User.objects.create_user(username='U'), - amount=Amount(settings.TALER_CURRENCY, 3) - ).save() - - def tearDown(self): - clear_db() + return response + + # test query #1 + r = histquery(delta="-4", direction="both") + rd = json.loads(r.content) + self.assertEqual(r.status_code, 200) + + # test query #2 + r = histquery(delta="+1", start="5", direction="both") + self.assertEqual(r.status_code, 200) + rd = json.loads(r.content) + self.assertEqual(r.status_code, 200) + self.assertEqual(rd["data"][0]["row_id"], 6) + + # test query #3 + r = histquery(delta="+1", start="2", direction="both") + self.assertEqual(r.status_code, 200) + rd = json.loads(r.content) + self.assertEqual(rd["data"][0]["wt_subject"], "c") + + # test query #4 + r = histquery(delta="-1", start="2", direction="both") + self.assertEqual(r.status_code, 200) + rd = json.loads(r.content) + self.assertEqual(rd["data"][0]["wt_subject"], "a") + + # test query #5 + r = histquery(delta="1", start="11", direction="both") + self.assertEqual(r.status_code, 200) + rd = json.loads(r.content) + self.assertEqual(len(rd["data"]), 0) - def test_subtraction(self): - user_bankaccount = BankAccount.objects.get( - user=User.objects.get(username='U') - ) - user_bankaccount.amount.subtract(Amount(settings.TALER_CURRENCY, 2)) - self.assertEqual( - Amount.cmp(Amount(settings.TALER_CURRENCY, 1), user_bankaccount.amount), 0 - ) class DBCustomColumnTestCase(TestCase): def setUp(self): - BankAccount(user=User.objects.create_user(username='U')).save() + BankAccount(user=User.objects.create_user(username="U")).save() def tearDown(self): clear_db() def test_exists(self): - user_bankaccount = BankAccount.objects.get( - user=User.objects.get(username='U') - ) - self.assertTrue(isinstance(user_bankaccount.amount, Amount)) + user_bankaccount = BankAccount.objects.get(user=User.objects.get(username="U")) + self.assertTrue(isinstance(user_bankaccount.balance, SignedAmount)) ## This tests whether a bank account goes debit and then goes >=0 ## again class DebitTestCase(TestCase): def setUp(self): - BankAccount(user=User.objects.create_user(username='U')).save() - BankAccount(user=User.objects.create_user(username='U0')).save() + BankAccount(user=User.objects.create_user(username="U")).save() + BankAccount(user=User.objects.create_user(username="U0")).save() def tearDown(self): clear_db() def test_green(self): - user_bankaccount = BankAccount.objects.get( - user=User.objects.get(username='U') - ) - self.assertEqual(False, user_bankaccount.debit) + user_bankaccount = BankAccount.objects.get(user=User.objects.get(username="U")) + self.assertTrue(user_bankaccount.balance.is_zero()) def test_red(self): - user_bankaccount = BankAccount.objects.get( - user=User.objects.get(username='U') - ) + user_bankaccount = BankAccount.objects.get(user=User.objects.get(username="U")) user_bankaccount0 = BankAccount.objects.get( - user=User.objects.get(username='U0') + user=User.objects.get(username="U0") ) wire_transfer( - Amount(settings.TALER_CURRENCY, 10, 0), user_bankaccount0, - user_bankaccount, "Go green" + Amount(settings.TALER_CURRENCY, 10, 0), + user_bankaccount0, + user_bankaccount, + "Go green", ) - tmp = Amount(settings.TALER_CURRENCY, 10) - self.assertEqual(0, Amount.cmp(user_bankaccount.amount, tmp)) - self.assertEqual(0, Amount.cmp(user_bankaccount0.amount, tmp)) - self.assertFalse(user_bankaccount.debit) - - self.assertTrue(user_bankaccount0.debit) wire_transfer( - Amount(settings.TALER_CURRENCY, 11), user_bankaccount, - user_bankaccount0, "Go red" + Amount(settings.TALER_CURRENCY, 11, 0), + user_bankaccount, + user_bankaccount0, + "Go red", ) - tmp.value = 1 - self.assertTrue(user_bankaccount.debit) - self.assertFalse(user_bankaccount0.debit) - self.assertEqual(0, Amount.cmp(user_bankaccount.amount, tmp)) - self.assertEqual(0, Amount.cmp(user_bankaccount0.amount, tmp)) + amt_one = SignedAmount.parse(f"{settings.TALER_CURRENCY}:1") - -class ParseAmountTestCase(TestCase): - def test_parse_amount(self): - ret = Amount.parse("KUDOS:4.0") - self.assertJSONEqual( - '{"value": 4, \ - "fraction": 0, \ - "currency": "KUDOS"}', ret.dump() - ) - ret = Amount.parse("KUDOS:4.3") - self.assertJSONEqual( - '{"value": 4, \ - "fraction": 30000000, \ - "currency": "KUDOS"}', ret.dump() - ) - ret = Amount.parse("KUDOS:4") - self.assertJSONEqual( - '{"value": 4, "fraction": 0, "currency": "KUDOS"}', ret.dump() - ) - ret = Amount.parse("KUDOS:4.") # forbid? - self.assertJSONEqual( - '{"value": 4, "fraction": 0, "currency": "KUDOS"}', ret.dump() - ) - try: - Amount.parse("Buggy") - except BadFormatAmount as err: - return - # make sure the control doesn't get here - self.assertEqual(True, False) + self.assertEqual(user_bankaccount.balance, -amt_one) + self.assertEqual(user_bankaccount0.balance, amt_one) class MeasureHistory(TestCase): def setUp(self): self.user_bankaccount0 = BankAccount( - user=User.objects.create_user(username='U0'), - amount=Amount(settings.TALER_CURRENCY, 3000) + user=User.objects.create_user(username="U0"), + balance=SignedAmount(True, Amount(settings.TALER_CURRENCY, 3000, 0)), ) self.user_bankaccount0.save() - user_bankaccount = BankAccount( - user=User.objects.create_user(username='U') - ) + user_bankaccount = BankAccount(user=User.objects.create_user(username="U")) user_bankaccount.save() self.ntransfers = 1000 @@ -866,8 +614,10 @@ class MeasureHistory(TestCase): for i in range(self.ntransfers): del i # to pacify PEP checkers wire_transfer( - Amount(settings.TALER_CURRENCY, 1), self.user_bankaccount0, - user_bankaccount, "bulk" + Amount(settings.TALER_CURRENCY, 1, 0), + self.user_bankaccount0, + user_bankaccount, + "bulk", ) def tearDown(self): @@ -880,7 +630,7 @@ class MeasureHistory(TestCase): timer = timeit.Timer( stmt="extract_history(self.user_bankaccount0, False)", setup="from talerbank.app.views import extract_history", - globals=locals() + globals=locals(), ) total_time = timer.timeit(number=1) allowed_time_per_record = 0.003 @@ -890,14 +640,14 @@ class MeasureHistory(TestCase): class BalanceTestCase(TestCase): def setUp(self): self.the_bank = BankAccount( - user=User.objects.create_user(username='U0', password='U0PASS'), - amount=Amount(settings.TALER_CURRENCY, 3) + user=User.objects.create_user(username="U0", password="U0PASS"), + balance=SignedAmount(True, Amount(settings.TALER_CURRENCY, 3, 0)), ) self.the_bank.save() user = BankAccount( - user=User.objects.create_user(username='U'), - amount=Amount(settings.TALER_CURRENCY, 10) + user=User.objects.create_user(username="U"), + balance=SignedAmount(True, Amount(settings.TALER_CURRENCY, 10, 0)), ) user.save() @@ -905,27 +655,27 @@ class BalanceTestCase(TestCase): # bank: 2, user: 11 wire_transfer( - Amount(settings.TALER_CURRENCY, 1), self.the_bank, user, "mock" + Amount(settings.TALER_CURRENCY, 1, 0), self.the_bank, user, "mock" ) # bank: 4, user: 9 wire_transfer( - Amount(settings.TALER_CURRENCY, 2), user, self.the_bank, "mock" + Amount(settings.TALER_CURRENCY, 2, 0), user, self.the_bank, "mock" ) # bank: -1, user: 14 wire_transfer( - Amount(settings.TALER_CURRENCY, 5), self.the_bank, user, "mock" + Amount(settings.TALER_CURRENCY, 5, 0), self.the_bank, user, "mock" ) # bank: 7, user: 6 (END) wire_transfer( - Amount(settings.TALER_CURRENCY, 8), user, self.the_bank, "mock" + Amount(settings.TALER_CURRENCY, 8, 0), user, self.the_bank, "mock" ) # bank: -3, user: 16 (END) wire_transfer( - Amount(settings.TALER_CURRENCY, 10), user, self.the_bank, "mock" + Amount(settings.TALER_CURRENCY, 10, 0), self.the_bank, user, "mock" ) self.client = Client() @@ -937,44 +687,23 @@ class BalanceTestCase(TestCase): self.client.login(username="U0", password="U0PASS") response = self.client.get( reverse("history", urlconf=urls), - { - "auth": "basic", - "delta": -30, - "direction": "both", - "account_number": 55 - }, # unused - **{ - "HTTP_AUTHORIZATION": make_auth_line("U0", "U0PASS") - } + {"delta": -30, "direction": "both", "account_number": 55}, + HTTP_AUTHORIZATION=make_auth_line("U0", "U0PASS"), ) data = response.content.decode("utf-8") self.assertEqual(response.status_code, 200) entries = json.loads(data) - acc_in = Amount(settings.TALER_CURRENCY) - acc_out = Amount(settings.TALER_CURRENCY) + acc_bal = SignedAmount(True, Amount(settings.TALER_CURRENCY, 10, 0)) + print("acc_bal start", acc_bal) - for entry in entries["data"]: - if entry["sign"] == "+": - acc_in.add(Amount(**entry["amount"])) + for entry in reversed(entries["data"]): + print("entry", entry) if entry["sign"] == "-": - acc_out.add(Amount(**entry["amount"])) - - expected_amount = Amount(settings.TALER_CURRENCY, 3) - try: - debit = False - acc_in.subtract(acc_out) - expected_amount.add(acc_in) - except ValueError: - # "out" is bigger than "in" - LOGGER.info("out > in") - acc_out.subtract(acc_in) - try: - expected_amount.subtract(acc_out) - except ValueError: - # initial amount wasn't enough to cover expenses - debit = True - acc_out.subtract(expected_amount) - expected_amount = acc_out - - self.assertEqual(Amount.cmp(expected_amount, self.the_bank.amount), 0) + acc_bal += SignedAmount.parse(entry["amount"]) + if entry["sign"] == "+": + acc_bal -= SignedAmount.parse(entry["amount"]) + print("acc_bal after", acc_bal) + + expected_amount = SignedAmount.parse(f"{settings.TALER_CURRENCY}:16.0") + self.assertEqual(acc_bal, expected_amount) diff --git a/talerbank/app/urls.py b/talerbank/app/urls.py index 28230ed..cabdc22 100644 --- a/talerbank/app/urls.py +++ b/talerbank/app/urls.py @@ -31,54 +31,45 @@ urlpatterns = [ "login/", auth_views.LoginView.as_view( template_name="login.html", - authentication_form=views.TalerAuthenticationForm + authentication_form=views.TalerAuthenticationForm, ), - name="login" + name="login", ), path("logout/", views.logout_view, name="logout"), path("accounts/register", views.register, name="register"), path("profile", views.profile_page, name="profile"), path("history", views.serve_history, name="history"), path("history-range", views.serve_history_range, name="history-range"), - path("reject", views.reject, name="reject"), path( "api/withdraw-operation/", views.api_withdraw_operation, - name="api-withdraw-operation" - ), - path( - "api/withdraw-headless", - views.withdraw_headless, - name="withdraw-headless" + name="api-withdraw-operation", ), + path("api/withdraw-headless", views.withdraw_headless, name="withdraw-headless"), path( "api/withdraw-headless-uri", views.withdraw_headless_uri, - name="withdraw-headless-uri" + name="withdraw-headless-uri", ), path("api/register", views.register_headless, name="register-headless"), path("start-withdrawal", views.start_withdrawal, name="start-withdrawal"), path( - "show-withdrawal/", - views.show_withdrawal, - name="withdraw-show" + "show-withdrawal/", views.show_withdrawal, name="withdraw-show" ), path( "confirm-withdrawal/", views.confirm_withdrawal, - name="withdraw-confirm" - ), - path( - "public-accounts", views.serve_public_accounts, name="public-accounts" + name="withdraw-confirm", ), + path("public-accounts", views.serve_public_accounts, name="public-accounts"), path( "public-accounts/", views.serve_public_accounts, - name="public-accounts" + name="public-accounts", ), path( "public-accounts//", views.serve_public_accounts, - name="public-accounts" + name="public-accounts", ), ] diff --git a/talerbank/app/views.py b/talerbank/app/views.py index a6902be..3df8505 100644 --- a/talerbank/app/views.py +++ b/talerbank/app/views.py @@ -45,7 +45,7 @@ from django.shortcuts import render, redirect from django.core.exceptions import ObjectDoesNotExist from datetime import datetime from .models import BankAccount, BankTransaction, TalerWithdrawOperation -from taler.util.amount import Amount +from taler.util.amount import Amount, SignedAmount import qrcode import qrcode.image.svg import lxml @@ -53,7 +53,6 @@ from .schemas import ( HistoryParams, HistoryRangeParams, URLParamValidationError, - RejectData, AddIncomingData, JSONFieldException, InvalidSession, @@ -129,18 +128,6 @@ class SameAccountException(Exception): self.taler_error_code = 5102 -## -# Exception raised when someone tries to reject a -# transaction, but they have no rights to accomplish -# such operation. -class RejectNoRightsException(Exception): - def __init__(self, msg): - super(RejectNoRightsException, self).__init__(msg) - self.hint = "Only original payer can reject." - self.http_status_code = 403 - self.taler_error_code = 5200 - - class UnhandledException(Exception): def __init__(self, msg="Unhandled exception happened!"): super(UnhandledException, self).__init__(msg) @@ -278,11 +265,12 @@ class InputDatalist(forms.TextInput): return html + datalist -## -# Form for sending wire transfers. It usually appears in the -# user profile page. -# class WTForm(forms.Form): + """ + Form for sending wire transfers. It usually appears in the + user profile page. + """ + amount = forms.FloatField( min_value=0.1, widget=forms.NumberInput(attrs={"class": "currency-input"}) ) @@ -333,13 +321,12 @@ def profile_page(request): is_success, is_failure, hint = get_session_hint(request, "profile_hint") context = dict( name=request.user.username, - balance=request.user.bankaccount.amount, - sign="-" if request.user.bankaccount.debit else "", + balance=request.user.bankaccount.balance, fail_message=is_failure, success_message=is_success, hint=hint, precision=settings.TALER_DIGITS, - currency=request.user.bankaccount.amount.currency, + currency=request.user.bankaccount.balance.amount.currency, account_no=request.user.bankaccount.account_no, wt_form=wtf, history=extract_history(request.user.bankaccount, -1 * (UINT64_MAX / 2 / 2)), @@ -423,16 +410,6 @@ def internal_register(request): user_account.save() bank_internal_account = BankAccount.objects.get(account_no=1) - # Raise: - # - # SameAccountException - # DebitLimitException - # CurrencyMismatch - # - # Amount group: - # BadFormatAmount - # NumberTooBig - # NegativeNumber wire_transfer( Amount(settings.TALER_CURRENCY, 100, 0), bank_internal_account, @@ -443,15 +420,13 @@ def internal_register(request): return user -## -# This method serves the request for programmatically -# registering a user. -# -# @param request Django-specific HTTP request object. -# @return Django-specific HTTP response object. @require_POST @csrf_exempt def register_headless(request): + """ + This method serves the request for programmatically + registering a user. + """ try: user = internal_register(request) @@ -465,15 +440,13 @@ def register_headless(request): return HttpResponse(status=200) -## -# This method serves the request for registering a user. -# If successful, it redirects the user to their profile page; -# otherwise it will show again the same form (currently, without -# displaying _any_ error/warning message.) -# -# @param request Django-specific HTTP request object. -# @return Django-specific HTTP response object. def register(request): + """ + This method serves the request for registering a user. + If successful, it redirects the user to their profile page; + otherwise it will show again the same form (currently, without + displaying _any_ error/warning message.) + """ if request.method != "POST": return render(request, "register.html") @@ -535,7 +508,7 @@ def logout_view(request): # @return the history array. def extract_history(account, delta, start=None): history = [] - qs = query_history(account, "both", delta, start, "descending") + qs = query_history(account, "both", delta, start) for item in qs: if item.credit_account == account: counterpart = item.debit_account @@ -589,7 +562,6 @@ def serve_public_accounts(request, name=None, page=None): # and django/python is not allowing slicing with big numbers. UINT64_MAX / 2 / 2, 0, - "descending", ).count() DELTA = 30 # '//' operator is NO floating point. @@ -708,7 +680,7 @@ def query_history_range(bank_account, direction, start, end, descending): # @param sign this value ("+"/"-") determines whether the history # entries will be younger / older than @a start. # @param ordering "descending" or anything else (for "ascending"). -def query_history(bank_account, direction, delta, start, ordering): +def query_history(bank_account, direction, delta, start): if start is None: if delta > 0: start = -1 @@ -722,7 +694,7 @@ def query_history(bank_account, direction, delta, start, ordering): qs = BankTransaction.objects.filter( direction_switch(bank_account, direction), sign_filter ) - order = "-id" if "descending" == ordering else "id" + order = "id" if (delta > 0) else "-id" return qs.order_by(order)[: abs(delta)] @@ -751,11 +723,11 @@ def build_history_response(qs, cancelled, user_account): history.append( dict( counterpart=counterpart, - amount=entry.amount.dump(), + amount=entry.amount.stringify(), sign=sign_, wt_subject=entry.subject, row_id=entry.id, - date=dict(t_ms=int(entry.date.timestamp())*1000) + date=dict(t_ms=int(entry.date.timestamp()) * 1000), ) ) return history @@ -786,8 +758,6 @@ def serve_history_range(request, user_account): history = build_history_response(qs, args.get("cancelled", "show"), user_account) - if not history: - return HttpResponse(status=204) return JsonResponse(dict(data=history), status=200) @@ -806,15 +776,13 @@ def serve_history(request, user_account): args.get("direction"), args.get("delta"), args.get("start", None), - args.get("ordering", "descending"), ) history = build_history_response(qs, args.get("cancelled", "show"), user_account) - if not history: - return HttpResponse(status=204) return JsonResponse(dict(data=history), status=200) + ## # Implements the HTTP basic auth schema. # @@ -830,64 +798,13 @@ def basic_auth(request): if len(tokens) != 2: raise LoginFailed("invalid Authorization header") - # decode the base64 content. + # decode the base64 content. if tokens[0] != "Basic": raise LoginFailed("Not supporting '%s' authorization method" % tokens[0]) username, password = base64.b64decode(tokens[1]).decode("utf-8").split(":") return django.contrib.auth.authenticate(username=username, password=password) -## -# Serve a request of /reject (for rejecting wire transfers). -# -# @param request Django-specific HTTP request object. -# @param user_account the account that is going to reject the -# transfer. Used to check whether they have this right -# or not (only accounts which _got_ payed can cancel the -# transaction.) -@transaction.atomic -@csrf_exempt -@require_http_methods(["PUT", "POST"]) -@login_via_headers -def reject(request, user_account): - - data = RejectData(json.loads(decode_body(request))) - - trans = BankTransaction.objects.get(id=data.get("row_id")) - if trans.credit_account.account_no != user_account.bankaccount.account_no: - raise RejectNoRightsException() - trans.cancelled = True - if trans.debit_account.debit: - # balance is negative - if 1 > Amount.cmp(trans.debit_account.amount, trans.amount): - # debit_account.amount <= trans.amount - trans.debit_account.debit = False - tmp = Amount(**trans.amount.dump()) - tmp.subtract(trans.debit_account.amount) - trans.debit_account.amount.set(**tmp.dump()) - else: - # debit_account > trans.amount - trans.debit_account.amount.subtract(trans.amount) - else: - # balance is positive, simply add - trans.debit_account.amount.add(trans.amount) - if trans.credit_account.debit: - # credit account balance is already negative - trans.credit_account.amount.add(trans.amount) - else: - if -1 == Amount.cmp(trans.credit_account.amount, trans.amount): - # credit_account.amount < trans.amount - trans.credit_account.debit = True - tmp = Amount(**trans.amount.dump()) - tmp.subtract(trans.credit_account.amount) - trans.credit_account.amount.set(**tmp.dump()) - else: - # credit_account.amount >= trans.amount - trans.credit_account.amount.subtract(trans.amount) - - trans.save() - return HttpResponse(status=204) - ## # Serve a request to make a wire transfer. Allows fintech @@ -906,17 +823,40 @@ def add_incoming(request, user_account): subject = "%s %s" % (data.get("subject"), data.get("exchange_url")) - credit_account = BankAccount.objects.get(account_no=data.get("credit_account")) + try: + credit_account = BankAccount.objects.get(account_no=data.get("credit_account")) + except BankAccount.DoesNotExist: + return JsonResponse( + { + "error": "Bank account not found" + }, + status=404, + ) + + + amount = Amount.parse(data.get("amount")) + + if amount.currency != settings.TALER_CURRENCY: + return JsonResponse( + { + "error": "Incorrect currency" + }, + status=400, + ) + wtrans = wire_transfer( - Amount.parse(data.get("amount")), + amount, user_account.bankaccount, credit_account, subject, ) return JsonResponse( - {"row_id": wtrans.id, "timestamp": dict(t_ms=(int(wtrans.date.timestamp())*1000))} + { + "row_id": wtrans.id, + "timestamp": dict(t_ms=(int(wtrans.date.timestamp()) * 1000)), + } ) @@ -927,10 +867,10 @@ def withdraw_headless_uri(request, user): data = WithdrawHeadlessUri(json.loads(decode_body(request))) amount = Amount.parse(data.get("amount")) user_account = BankAccount.objects.get(user=user) - debt_threshold = Amount.parse(settings.TALER_MAX_DEBT) - if not check_transfer_allowed( - user_account.amount, user_account.debit, debt_threshold, amount - ): + withdraw_amount = SignedAmount(True, amount) + debt_threshold = SignedAmount.parse(settings.TALER_MAX_DEBT) + user_balance = SignedAmount(not user_account.debit, user_account.amount) + if user_balance - amount < -debt_threshold: raise DebitLimitException( f"Aborting payment initiated by '{user_account.user.username}', debit limit crossed." ) @@ -941,15 +881,13 @@ def withdraw_headless_uri(request, user): return JsonResponse({"taler_withdraw_uri": taler_withdraw_uri,}) -## -# Serves a headless withdrawal request for the Taler protocol. -# -# @param request Django-specific HTTP request. -# @return Django-specific HTTP response object. @login_via_headers @csrf_exempt @require_POST def withdraw_headless(request, user): + """ + Serves a headless withdrawal request for the Taler protocol. + """ data = WithdrawHeadless(json.loads(decode_body(request))) sender_payto = "payto://x-taler-bank/%s/%d" % ( @@ -965,7 +903,11 @@ def withdraw_headless(request, user): else: exchange_accno = get_acct_from_payto(exchange_payto) - exchange_bankaccount = BankAccount.objects.get(account_no=exchange_accno) + try: + exchange_bankaccount = BankAccount.objects.get(account_no=exchange_accno) + except ObjectDoesNotExist: + err = dict(hint="Bank account not found") + return JsonResponse(err, status=404) wire_transfer( Amount.parse(data.get("amount")), @@ -977,10 +919,12 @@ def withdraw_headless(request, user): return JsonResponse(ret_obj) -# Endpoint used by the browser and wallet to check withdraw status and -# put in the exchange info. @csrf_exempt def api_withdraw_operation(request, withdraw_id): + """ + Endpoint used by the browser and wallet to check withdraw status and + put in the exchange info. + """ try: op = TalerWithdrawOperation.objects.get(withdraw_id=withdraw_id) except ObjectDoesNotExist: @@ -1038,32 +982,20 @@ def api_withdraw_operation(request, withdraw_id): return JsonResponse(dict(error="only GET and POST are allowed"), status=305) -def check_transfer_allowed(balance, balance_is_debit, debt_limit, transfer_amount): - if balance_is_debit: - total_debt = Amount(**transfer_amount.dump()) - total_debt.add(balance) - return Amount.cmp(total_debt, debt_limit) <= 0 - max_transfer = Amount(**balance.dump()) - max_transfer.add(debt_limit) - return Amount.cmp(transfer_amount, max_transfer) <= 0 - - -## -# Serve a Taler withdrawal request; takes the amount chosen -# by the user, and builds a response to trigger the wallet into -# the withdrawal protocol -# -# @param request Django-specific HTTP request. -# @return Django-specific HTTP response object. @login_required @require_POST def start_withdrawal(request): + """ + Serve a Taler withdrawal request; takes the amount chosen + by the user, and builds a response to trigger the wallet into + the withdrawal protocol + """ user_account = BankAccount.objects.get(user=request.user) amount = Amount.parse(request.POST.get("kudos_amount", "not-given")) - debt_threshold = Amount.parse(settings.TALER_MAX_DEBT) - if not check_transfer_allowed( - user_account.amount, user_account.debit, debt_threshold, amount - ): + withdraw_amount = SignedAmount(True, amount) + debt_threshold = SignedAmount.parse(settings.TALER_MAX_DEBT) + user_balance = user_account.balance + if user_balance - withdraw_amount < -debt_threshold: raise DebitLimitException( f"Aborting payment initiated by '{user_account.user.username}', debit limit crossed." ) @@ -1147,17 +1079,12 @@ def confirm_withdrawal(request, withdraw_id): raise Exception("not reached") -## -# Make a wire transfer between two accounts (internal to the bank) -# -# @param amount (object type) how much money the wire transfer is worth. -# FIXME: a check about whether this value is zero is missing -# @param debit_account the account that gives money. -# @param credit_account the account that receives money. -# @return a @a BankTransaction object. def wire_transfer(amount, debit_account, credit_account, subject): + """ + Make a wire transfer between two accounts (internal to the bank) + """ LOGGER.debug( - "%s => %s, %s, %s" + "transfering %s => %s, %s, %s" % ( debit_account.account_no, credit_account.account_no, @@ -1176,45 +1103,19 @@ def wire_transfer(amount, debit_account, credit_account, subject): subject=subject, ) - if debit_account.debit: - debit_account.amount.add(amount) - - elif -1 == Amount.cmp(debit_account.amount, amount): - debit_account.debit = True - tmp = Amount(**amount.dump()) - tmp.subtract(debit_account.amount) - debit_account.amount.set(**tmp.dump()) - else: - debit_account.amount.subtract(amount) - - if not credit_account.debit: - credit_account.amount.add(amount) - elif Amount.cmp(amount, credit_account.amount) == 1: - credit_account.debit = False - tmp = Amount(**amount.dump()) - tmp.subtract(credit_account.amount) - credit_account.amount.set(**tmp.dump()) - else: - credit_account.amount.subtract(amount) - - # Check here if any account went beyond the allowed - # debit threshold. - - threshold = Amount.parse(settings.TALER_MAX_DEBT) if debit_account.user.username == "Bank": - threshold = Amount.parse(settings.TALER_MAX_DEBT_BANK) - - LOGGER.info( - f"Account {debit_account.user.username} " - + f"(bal {debit_account.amount.stringify()}, thr {threshold.stringify()} requested transfer of" - + f"{threshold.stringify()} to account {credit_account.user.username}" - ) + threshold = -SignedAmount.parse(settings.TALER_MAX_DEBT_BANK) + else: + threshold = -SignedAmount.parse(settings.TALER_MAX_DEBT) - if Amount.cmp(debit_account.amount, threshold) > 0 and debit_account.debit: + if debit_account.balance - SignedAmount(True, amount) < threshold: raise DebitLimitException( f"Aborting payment initiated by '{debit_account.user.username}', debit limit crossed." ) + debit_account.balance -= SignedAmount(True, amount) + credit_account.balance += SignedAmount(True, amount) + with transaction.atomic(): debit_account.save() credit_account.save() diff --git a/talerbank/jinja2.py b/talerbank/jinja2.py index 7287e1b..4decadd 100644 --- a/talerbank/jinja2.py +++ b/talerbank/jinja2.py @@ -131,13 +131,15 @@ def amount_stringify(amount): def environment(**options): env = Environment(**options) - env.globals.update({ - 'static': static, - 'url': url, - 'settings_value': settings_value, - 'env': env_get, - 'is_valid_amount': is_valid_amount, - 'amount_stringify': amount_stringify, - 'tojson': tojson, - }) + env.globals.update( + { + "static": static, + "url": url, + "settings_value": settings_value, + "env": env_get, + "is_valid_amount": is_valid_amount, + "amount_stringify": amount_stringify, + "tojson": tojson, + } + ) return env diff --git a/talerbank/settings.py b/talerbank/settings.py index d50808b..6b39226 100644 --- a/talerbank/settings.py +++ b/talerbank/settings.py @@ -18,8 +18,7 @@ from taler.util.talerconfig import TalerConfig, ConfigurationError LOGGER = logging.getLogger(__name__) -LOGGER.info("DJANGO_SETTINGS_MODULE: %s" \ - % os.environ.get("DJANGO_SETTINGS_MODULE")) +LOGGER.info("DJANGO_SETTINGS_MODULE: %s" % os.environ.get("DJANGO_SETTINGS_MODULE")) TC = TalerConfig.from_file(os.environ.get("TALER_CONFIG_FILE")) @@ -38,7 +37,7 @@ if not SECRET_KEY: " TALER_BANK_SECRET_KEY env variable," " generating random secret" ) - SECRET_KEY = base64.b64encode(os.urandom(32)).decode('utf-8') + SECRET_KEY = base64.b64encode(os.urandom(32)).decode("utf-8") # SECURITY WARNING: don't run with debug turned on in production! @@ -58,41 +57,43 @@ LOGIN_REDIRECT_URL = "index" # Application definition INSTALLED_APPS = [ - 'django.contrib.auth', 'django.contrib.contenttypes', - 'django.contrib.sessions', 'django.contrib.messages', - 'django.contrib.staticfiles', 'talerbank.app' + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "talerbank.app", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'talerbank.app.middleware.ExceptionMiddleware', - 'talerbank.app.middleware.DecompressionMiddleware' + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "talerbank.app.middleware.ExceptionMiddleware", + "talerbank.app.middleware.DecompressionMiddleware", ] -TEMPLATES = [{ - 'BACKEND': - 'django.template.backends.jinja2.Jinja2', - 'DIRS': [ - os.path.join(BASE_DIR, "talerbank/app/static/web-common/"), - os.path.join(BASE_DIR, "talerbank/app/templates") - ], - 'OPTIONS': { - 'environment': 'talerbank.jinja2.environment' +TEMPLATES = [ + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "DIRS": [ + os.path.join(BASE_DIR, "talerbank/app/static/web-common/"), + os.path.join(BASE_DIR, "talerbank/app/templates"), + ], + "OPTIONS": {"environment": "talerbank.jinja2.environment"}, } -}] +] # Disable those, since they don't work with # jinja2 anyways. TEMPLATE_CONTEXT_PROCESSORS = [] -WSGI_APPLICATION = 'talerbank.wsgi.application' +WSGI_APPLICATION = "talerbank.wsgi.application" # Database # https://docs.djangoproject.com/en/1.9/ref/settings/#databases @@ -103,17 +104,15 @@ DBNAME = TC.value_string("bank", "database", required=True) DBNAME = os.environ.get("TALER_BANK_ALTDB", DBNAME) if not DBNAME: - raise Exception("DB not specified (neither in config or as" \ - "cli argument)") + raise Exception("DB not specified (neither in config or as" "cli argument)") LOGGER.info("dbname: %s" % DBNAME) -CHECK_DBSTRING_FORMAT = re.search( - r"[a-z]+:///[a-z]+([\?][a-z]+=[a-z/]+)?", DBNAME -) +CHECK_DBSTRING_FORMAT = re.search(r"[a-z]+:///[a-z]+([\?][a-z]+=[a-z/]+)?", DBNAME) if not CHECK_DBSTRING_FORMAT: - LOGGER.error("Bad db string given '%s', respect the format" \ - "'dbtype:///dbname'" % DBNAME) + LOGGER.error( + "Bad db string given '%s', respect the format" "'dbtype:///dbname'" % DBNAME + ) sys.exit(2) DBCONFIG = {} @@ -124,7 +123,7 @@ if DB_URL.scheme not in ("postgres") or DB_URL.scheme == "": LOGGER.error("DB '%s' is not supported" % DB_URL.scheme) sys.exit(1) if DB_URL.scheme == "postgres": - DBCONFIG["ENGINE"] = 'django.db.backends.postgresql_psycopg2' + DBCONFIG["ENGINE"] = "django.db.backends.postgresql_psycopg2" DBCONFIG["NAME"] = DB_URL.path.lstrip("/") if not DB_URL.netloc: @@ -145,21 +144,21 @@ DATABASES["default"] = DBCONFIG # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ - {'NAME': 'django.contrib.auth.password_validation' \ - '.UserAttributeSimilarityValidator'}, - {'NAME': 'django.contrib.auth.password_validation' \ - '.MinimumLengthValidator'}, - {'NAME': 'django.contrib.auth.password_validation' \ - '.CommonPasswordValidator'}, - {'NAME': 'django.contrib.auth.password_validation' \ - '.NumericPasswordValidator'}] + { + "NAME": "django.contrib.auth.password_validation" + ".UserAttributeSimilarityValidator" + }, + {"NAME": "django.contrib.auth.password_validation" ".MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation" ".CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation" ".NumericPasswordValidator"}, +] # Internationalization # https://docs.djangoproject.com/en/1.9/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -173,7 +172,7 @@ USE_TZ = False # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.9/howto/static-files/ -STATIC_URL = '/static/' +STATIC_URL = "/static/" STATICFILES_DIRS = [ os.path.join(BASE_DIR, "talerbank/app/static"), @@ -189,9 +188,7 @@ except ConfigurationError as exc: LOGGER.error(exc) sys.exit(3) -TALER_MAX_DEBT = TC.value_string( - "bank", "MAX_DEBT", default="%s:50.0" % TALER_CURRENCY -) +TALER_MAX_DEBT = TC.value_string("bank", "MAX_DEBT", default="%s:50.0" % TALER_CURRENCY) TALER_MAX_DEBT_BANK = TC.value_string( "bank", "MAX_DEBT_BANK", default="%s:0.0" % TALER_CURRENCY ) @@ -199,10 +196,15 @@ TALER_MAX_DEBT_BANK = TC.value_string( TALER_DIGITS = TC.value_int("bank", "NDIGITS", default=2) # Order matters TALER_PREDEFINED_ACCOUNTS = [ - 'Bank', 'Exchange', 'Tor', 'GNUnet', 'Taler', 'FSF', 'Tutorial', 'Survey' + "Bank", + "Exchange", + "Tor", + "GNUnet", + "Taler", + "FSF", + "Tutorial", + "Survey", ] -TALER_EXPECTS_DONATIONS = ['Tor', 'GNUnet', 'Taler', 'FSF'] +TALER_EXPECTS_DONATIONS = ["Tor", "GNUnet", "Taler", "FSF"] TALER_SUGGESTED_EXCHANGE = TC.value_string("bank", "suggested_exchange") -TALER_SUGGESTED_EXCHANGE_PAYTO = TC.value_string( - "bank", "suggested_exchange_payto" -) +TALER_SUGGESTED_EXCHANGE_PAYTO = TC.value_string("bank", "suggested_exchange_payto") -- cgit v1.2.3