def bonus_filters(query, add_param, endpoint_users, **kwargs):
     endpoints = Table('endpoints')
     return (query.where(
         Exists(
             Query.from_(endpoints).where(
                 endpoints.id == endpoint_users.endpoint_id).
             where(endpoints.sunsets_on > Now() - Interval(days=27)).where(
                 endpoints.sunsets_on < Now()).where(
                     DatePart('day', endpoints.sunsets_on -
                              Now()) < (30 - Floor(
                                  DatePart(
                                      'day', endpoint_users.created_at -
                                      endpoints.sunsets_on) / 3) * 3)))))
예제 #2
0
def show(id: int, authorization=Header(None)):
    if authorization is None:
        return Response(status_code=401)

    request_cost = 1
    with LazyItgs() as itgs:
        user_id, _, perms = users.helper.get_permissions_from_header(
            itgs, authorization,
            (helper.VIEW_OTHERS_AUTHENTICATION_METHODS_PERM,
             helper.CAN_VIEW_DELETED_AUTHENTICATION_METHODS_PERM,
             *ratelimit_helper.RATELIMIT_PERMISSIONS))

        if not ratelimit_helper.check_ratelimit(itgs, user_id, perms,
                                                request_cost):
            return Response(status_code=429,
                            headers={'x-request-cost': str(request_cost)})

        if user_id is None:
            return Response(status_code=403,
                            headers={'x-request-cost': str(request_cost)})

        can_view_others_auth_methods = helper.VIEW_OTHERS_AUTHENTICATION_METHODS_PERM in perms
        can_view_deleted_auth_methods = helper.CAN_VIEW_DELETED_AUTHENTICATION_METHODS_PERM in perms

        auth_methods = Table('password_authentications')
        query = (Query.from_(auth_methods).select(
            auth_methods.human,
            auth_methods.deleted).where(auth_methods.id == Parameter('%s')))
        args = [id]

        if not can_view_others_auth_methods:
            query = query.where(auth_methods.user_id == Parameter('%s'))
            args.append(user_id)

        if not can_view_deleted_auth_methods:
            query = query.where(auth_methods.deleted.eq(False))

        itgs.read_cursor.execute(query.get_sql(), args)
        row = itgs.read_cursor.fetchone()
        if row is None:
            return Response(status_code=404,
                            headers={'x-request-cost': str(request_cost)})

        (main, deleted) = row

        authtokens = Table('authtokens')
        itgs.read_cursor.execute(
            Query.from_(authtokens).select(Count(
                Star())).where(authtokens.expires_at < Now()).where(
                    authtokens.source_type == Parameter('%s')).where(
                        authtokens.source_id == Parameter('%s')).get_sql(),
            ('password_authentication', id))
        (active_grants, ) = itgs.read_cursor.fetchone()

        return JSONResponse(status_code=200,
                            content=models.AuthMethod(
                                main=main,
                                deleted=deleted,
                                active_grants=active_grants).dict(),
                            headers={'x-request-cost': str(request_cost)})
예제 #3
0
    def test_failed_claim_token(self):
        with helper.clear_tables(self.conn, self.cursor, ['users']):
            users = Table('users')
            self.cursor.execute(
                Query.into(users).columns(users.username).insert(
                    Parameter('%s')).returning(users.id).get_sql(),
                ('testuser', ))
            (user_id, ) = self.cursor.fetchone()
            claim_tokens = Table('claim_tokens')
            self.cursor.execute(
                Query.into(claim_tokens).columns(
                    claim_tokens.user_id, claim_tokens.token,
                    claim_tokens.expires_at).insert(Parameter('%s'),
                                                    Parameter('%s'),
                                                    Now()).get_sql(),
                (user_id, 'testtoken'))
            self.conn.commit()

            r = requests.post(f'{HOST}/users/claim',
                              json={
                                  'user_id': user_id,
                                  'claim_token': 'testtoken2',
                                  'password': '******',
                                  'captcha': 'notoken'
                              })
            self.assertNotEqual(200, r.status_code)
            self.assertLess(r.status_code, 500)
            pauths = Table('password_authentications')
            self.cursor.execute(
                Query.from_(pauths).select(pauths.user_id, pauths.human,
                                           pauths.hash_name, pauths.hash,
                                           pauths.salt,
                                           pauths.iterations).get_sql())
            row = self.cursor.fetchone()
            self.assertIsNone(row)
예제 #4
0
    def test_authtoken_to_users_me(self):
        with helper.clear_tables(self.conn, self.cursor, ['users']):
            users = Table('users')
            self.cursor.execute(
                Query.into(users).columns(users.username).insert(
                    Parameter('%s')).returning(users.id).get_sql(),
                ('testuser', ))
            (user_id, ) = self.cursor.fetchone()
            authtokens = Table('authtokens')
            self.cursor.execute(
                Query.into(authtokens).columns(
                    authtokens.user_id, authtokens.token,
                    authtokens.expires_at, authtokens.source_type,
                    authtokens.source_id).insert(Parameter('%s'),
                                                 Parameter('%s'),
                                                 Now() + Interval(hours=1),
                                                 Parameter('%s'),
                                                 Parameter('%s')).get_sql(),
                (user_id, 'testtoken', 'other', 1))
            self.conn.commit()

            r = requests.get(f'{HOST}/users/{user_id}/me',
                             headers={'Authorization': 'bearer testtoken'})
            r.raise_for_status()
            self.assertEqual(r.status_code, 200)

            body = r.json()
            self.assertIsInstance(body, dict)
            self.assertIsInstance(body.get('username'), str)
            self.assertEqual(len(body), 1)
            self.assertEqual(body['username'], 'testuser')

            # headers
            self.assertIsInstance(r.headers.get('cache-control'), str)
            cc = r.headers.get('cache-control')
            self.assertIn('private', cc)
            self.assertIn('max-age', cc)
            self.assertIn('stale-while-revalidate', cc)
            self.assertIn('stale-if-error', cc)

            split_cache_control = cc.split(', ')
            split_cache_control.remove('private')
            cc_args = dict([itm.split('=') for itm in split_cache_control])
            for key in list(cc_args.keys()):
                cc_args[key] = int(cc_args[key])
            self.assertGreater(cc_args['max-age'], 0)
            self.assertGreater(cc_args['stale-while-revalidate'], 0)
            self.assertGreater(cc_args['stale-if-error'], 0)
예제 #5
0
    def test_claim_token_to_passwd_auth(self):
        # users will cascade to everything
        with helper.clear_tables(self.conn, self.cursor, ['users']):
            users = Table('users')
            self.cursor.execute(
                Query.into(users).columns(users.username).insert(
                    Parameter('%s')).returning(users.id).get_sql(),
                ('testuser', ))
            (user_id, ) = self.cursor.fetchone()
            claim_tokens = Table('claim_tokens')
            self.cursor.execute(
                Query.into(claim_tokens).columns(
                    claim_tokens.user_id, claim_tokens.token,
                    claim_tokens.expires_at).insert(Parameter('%s'),
                                                    Parameter('%s'),
                                                    Now()).get_sql(),
                (user_id, 'testtoken'))
            self.conn.commit()

            r = requests.post(f'{HOST}/users/claim',
                              json={
                                  'user_id': user_id,
                                  'claim_token': 'testtoken',
                                  'password': '******',
                                  'captcha': 'notoken'
                              })
            r.raise_for_status()
            self.assertEqual(r.status_code, 200)

            pauths = Table('password_authentications')
            self.cursor.execute(
                Query.from_(pauths).select(pauths.user_id, pauths.human,
                                           pauths.hash_name, pauths.hash,
                                           pauths.salt,
                                           pauths.iterations).get_sql())
            row = self.cursor.fetchone()
            self.assertIsNotNone(row)
            self.assertIsNone(self.cursor.fetchone())
            self.assertEqual(row[0], user_id)
            self.assertTrue(row[1])

            exp_hash = b64encode(
                pbkdf2_hmac(row[2], 'testpass'.encode('utf-8'),
                            row[4].encode('utf-8'), row[5])).decode('ascii')
            self.assertEqual(row[3], exp_hash)
예제 #6
0
def scan_for_expired_temp_bans(itgs: LazyIntegrations, version: float) -> None:
    """Scans for any expired temporary bans in the temporary_bans table. For
    any rows that are found the corresponding users permission cache is flushed
    and the row is deleted."""

    temp_bans = Table('temporary_bans')
    users = Table('users')

    limit_per_iteration = 100
    # I don't anticipate there being that many temp bans that expire, so the
    # fact this races isn't that big of a concern. Furthermore, flushing the
    # cache on the same user twice in a row won't cause any issues. However,
    # I still implement the limit and looping to avoid OOM if for some reason
    # once in a blue moon a ton of temporary bans expire at once

    while True:
        itgs.write_cursor.execute(
            Query.from_(temp_bans).join(users).on(
                users.id == temp_bans.user_id).select(
                    temp_bans.id, users.username, temp_bans.subreddit,
                    temp_bans.created_at, temp_bans.ends_at).where(
                        temp_bans.ends_at < Now() + Interval(minutes=1)).limit(
                            limit_per_iteration).get_sql())

        rows = itgs.read_cursor.fetchall()

        for (rowid, username, subreddit, created_at, ends_at) in rows:
            itgs.logger.print(
                Level.INFO,
                'Detected a temporary ban on /u/{} in /r/{} at {} expired at {}; '
                + ' clearing users permission cache [rowid = {}]', username,
                subreddit, created_at, ends_at, rowid)
            flush_cache(itgs, username)

        if rows:
            itgs.write_cursor.execute(
                Query.from_(temp_bans).delete().where(
                    temp_bans.id.isin([Parameter('%s')
                                       for _ in rows])).get_sql(),
                [row[0] for row in rows])
            itgs.write_conn.commit()

        if len(rows) < limit_per_iteration:
            break
예제 #7
0
def try_handle_deprecated_call(
        itgs: LazyItgs,
        request: Request,
        endpoint_slug: str,
        user_id: int = None) -> Response:
    """Attempts to fully handle the deprecated call. If the underlying
    functionality on this call should not be provided then this will
    return the Response that should be given instead.

    Arguments:
    - `itgs (LazyItgs)`: The lazy integrations to use for connecting to
      networked components.
    - `request (Request)`: The underlying starlette request, which we will
      use for checking for shared query parameters like `deprecated`.
    - `endpoint_slug (str)`: The internal name we have for this
      endpoint, which will let us find the description, reason for deprecation,
      and list of alternatives in the database (main table: `endpoints`).
    - `user_id (int, None)`: If the request was authenticated in any way that
      could be recognized with `find_bearer_token`, and the token was valid,
      this should be the id of the authenticated user.

    Returns:
        - `resp (Response, None)`: If the response for the endpoint should be
          overriden, this is the response that should be used. Otherwise this
          is None.
    """
    endpoints = Table('endpoints')
    itgs.read_cursor.execute(
        Query.from_(endpoints).select(
            endpoints.id,
            endpoints.deprecated_on,
            endpoints.sunsets_on
        ).where(endpoints.slug == Parameter('%s'))
        .get_sql(),
        (endpoint_slug,)
    )
    row = itgs.read_cursor.fetchone()
    if row is None:
        return None

    host = URL(
        scope={
            'scheme': 'https',
            'server': (request.headers['x-real-host'], 443),
            'root_path': '/api',
            'path': request.url.path,
            'query_string': request.url.query.encode('utf-8'),
            'headers': {}
        }
    )
    ip_address = request.headers.get('x-real-ip', '')
    user_agent = request.headers.get('user-agent', '')

    (
        endpoint_id,
        deprecated_on,
        sunsets_on
    ) = row

    if deprecated_on is None:
        return None

    if sunsets_on is None:
        itgs.logger.print(
            Level.WARN,
            'The endpoint slug {} is deprecated but does not have a sunset '
            'date set! This should not happen; the maximum sunsetting time '
            'of 36 months will be assigned',
            endpoint_slug
        )
        itgs.write_cursor.execute(
            Query.update(endpoints)
            .set(
                endpoints.sunsets_on,
                Coalesce(endpoints.sunsets_on, Now() + Interval(months=36))
            )
            .where(endpoints.slug == Parameter('%s'))
            .returning(endpoints.sunsets_on)
            .get_sql(),
            (endpoint_slug,)
        )
        (sunsets_on,) = itgs.write_cursor.fetchone()
        itgs.write_conn.commit()

    curtime = datetime.utcnow()

    if curtime.date() < deprecated_on:
        return None

    # 2pm UTC = 10am est = 7am pst
    sunset_time = datetime(
        sunsets_on.year, sunsets_on.month, sunsets_on.day,
        14, tzinfo=curtime.tzinfo
    )

    if curtime >= sunset_time + timedelta(days=31):
        # No logging, can't be suppressed, provides no info
        if request.method not in ('GET', 'HEAD'):
            return Response(
                status_code=405,
                headers={
                    'Allow': 'GET, HEAD'
                }
            )

        return Response(
            status_code=404,
            headers=SUNSETTED_HEADERS
        )

    if curtime >= sunset_time:
        # No logging, can't be suppressed, provides info
        if request.method == 'HEAD':
            return Response(status_code=400)

        return JSONResponse(
            status_code=400,
            content={
                'deprecated': True,
                'sunsetted': True,
                'retryable': False,
                'error': (
                    'This endpoint has been deprecated since {} and was sunsetted on {}, '
                    'meaning that it can no longer be used. For the reason for deprecation '
                    'and how to migrate off, visit {}://{}/endpoints.html?slug={}'
                ).format(
                    deprecated_on.strftime('%B %d, %Y'),
                    sunsets_on.strftime('%B %d, %Y'),
                    host.scheme,
                    host.netloc,
                    endpoint_slug
                )
            },
            headers=SUNSETTED_HEADERS if request.method == 'GET' else None
        )

    if request.query_params.get('deprecated') == 'true':
        # This flag suppresses all behavior before sunset, including logging
        return None

    if user_id is not None:
        ip_address = None
        user_agent = None

    if curtime >= sunset_time - timedelta(days=14):
        store_response(itgs, user_id, ip_address, user_agent, endpoint_id, 'error')
        return JSONResponse(
            status_code=400,
            content={
                'deprecated': True,
                'sunsetted': False,
                'retryable': False,
                'error': (
                    'This endpoint has been deprecated since {deprecated_on} and will sunset '
                    'on {sunsets_on}. For the reason for deprecation and how to migrate off, '
                    'visit {scheme}://{netloc}/endpoints.html?slug={slug}. To continue using '
                    'this endpoint until {sunsets_on} you must acknowledge this warning by '
                    'setting the query parameter "deprecated" with the value "true". For '
                    'example: {scheme}://{netloc}{path}?{query_params}{opt_ambersand}'
                    'deprecated=true{opt_hashtag}{fragment}'
                ).format(
                    deprecated_on=deprecated_on.strftime('%B %d, %Y'),
                    sunsets_on=sunsets_on.strftime('%B %d, %Y'),
                    scheme=host.scheme,
                    netloc=host.netloc,
                    path=host.path,
                    query_params=host.query_params,
                    opt_ambersand='' if host.query_params == '' else '&',
                    opt_hashtag='' if host.fragment == '' else '#',
                    fragment=host.fragment,
                    slug=endpoint_slug
                )
            },
            headers={
                'Cache-Control': 'no-store'
            }
        )

    if user_id is None:
        # We will error them if they have <5 errors this month or it's
        # within 30 days of sunsetting and they have received <5 errors
        # this week
        endpoint_users = Table('endpoint_users')
        std_query = (
            Query.from_(endpoint_users)
            .select(Count(Star()))
            .where(endpoint_users.ip_address == Parameter('%s'))
            .where(endpoint_users.user_agent == Parameter('%s'))
            .where(endpoint_users.response_type == Parameter('%s'))
            # notnull ensure postgres uses matching index
            .where(endpoint_users.ip_address.notnull())
            .where(endpoint_users.user_agent.notnull())
        )
        std_args = [
            ip_address,
            user_agent,
            'error'
        ]
        itgs.read_cursor.execute(
            std_query
            .where(endpoint_users.created_at > DateTrunc('month', Now()))
            .get_sql(),
            std_args
        )
        (errors_this_month,) = itgs.read_cursor.fetchone()

        should_error = errors_this_month < 5
        if not should_error and curtime >= sunset_time - timedelta(days=30):
            itgs.read_cursor.execute(
                std_query
                .where(endpoint_users.created_at > Now() - Interval(days=7))
                .get_sql(),
                std_args
            )
            (errors_this_week,) = itgs.read_cursor.fetchone()
            should_error = errors_this_week < 5

        if should_error:
            store_response(itgs, None, ip_address, user_agent, endpoint_id, 'error')
            return JSONResponse(
                status_code=400,
                content={
                    'deprecated': True,
                    'sunsetted': False,
                    'retryable': True,
                    'error': (
                        'This endpoint has been deprecated since {deprecated_on} and will '
                        'sunset on {sunsets_on}. Since your request is not authenticated '
                        'the only means to alert you of the sunset date is to fail some of '
                        'your requests. You may pass the query parameter `deprecated=true` '
                        'to suppress this behavior. We will only fail 5 requests per month '
                        'until it gets closer to the sunset date.\n\n'
                        'Check {scheme}://{netloc}/endpoints.html?slug={slug} for information '
                        'about why this endpoint was deprecated and how to migrate.'
                    ).format(
                        deprecated_on=deprecated_on.strftime('%B %d, %Y'),
                        sunsets_on=sunsets_on.strftime('%B %d, %Y'),
                        scheme=host.scheme,
                        netloc=host.netloc,
                        slug=endpoint_slug
                    )
                },
                headers={
                    'Cache-Control': 'no-store'
                }
            )

    store_response(itgs, user_id, ip_address, user_agent, endpoint_id, 'passthrough')
    return None
예제 #8
0
def user_with_token(
        conn, cursor,
        add_perms=None,
        username='******',
        token='testtoken'):
    """Creates a user with an authorization token, returning the id of the
    user and the token to pass. This will delete the generated rows when
    finished.
    """
    users = Table('users')
    cursor.execute(
        Query.into(users).columns(users.username)
        .insert(Parameter('%s'))
        .returning(users.id).get_sql(),
        (username,)
    )
    (user_id,) = cursor.fetchone()
    authtokens = Table('authtokens')
    cursor.execute(
        Query.into(authtokens).columns(
            authtokens.user_id, authtokens.token, authtokens.expires_at,
            authtokens.source_type, authtokens.source_id
        ).insert(
            Parameter('%s'), Parameter('%s'), Now() + Interval(hours=1),
            Parameter('%s'), Parameter('%s')
        )
        .returning(authtokens.id)
        .get_sql(),
        (user_id, token, 'other', 1)
    )
    (auth_id,) = cursor.fetchone()
    perms = Table('permissions')
    auth_perms = Table('authtoken_permissions')
    perm_ids_to_delete = []
    if add_perms:
        for perm in add_perms:
            cursor.execute(
                Query.into(perms).columns(perms.name, perms.description)
                .insert(Parameter('%s'), Parameter('%s'))
                .on_conflict(perms.name).do_nothing()
                .returning(perms.id)
                .get_sql(),
                (perm, 'Testing')
            )
            row = cursor.fetchone()
            if row is not None:
                perm_ids_to_delete.append(row[0])
                cursor.execute(
                    Query.from_(perms).select(perms.id)
                    .where(perms.name == Parameter('%s'))
                    .get_sql(),
                    (perm,)
                )
                row = cursor.fetchone()
        cursor.execute(
            Query.into(auth_perms)
            .columns(auth_perms.authtoken_id, auth_perms.permission_id)
            .from_(perms).select(Parameter('%s'), perms.id)
            .where(perms.name.isin([Parameter('%s') for _ in add_perms]))
            .get_sql(),
            [auth_id] + list(add_perms)
        )

    conn.commit()
    try:
        yield (user_id, token)
    finally:
        conn.rollback()
        cursor.execute(
            Query.from_(users).delete().where(users.id == Parameter('%s'))
            .get_sql(),
            (user_id,)
        )
        for perm in perm_ids_to_delete:
            cursor.execute(
                Query.from_(perms).delete()
                .where(
                    perms.id.isin(
                        [Parameter('%s') for _ in perm_ids_to_delete]
                    )
                )
                .get_sql(),
                perm_ids_to_delete
            )
        conn.commit()
def destroy(req_user_id: int, captcha: str, authorization=Header(None)):
    """Purges our accessible stores of the given users demographics. This
    operation is not reversible and will destroy our history of who knew what
    about this user. We will not allow the user to submit further information
    once they do this.

    This should only be done in extreme circumstances, or by the users request.
    We would much prefer users update their demographic information to all
    blanks, which will _also_ prevent anyone from using the website to access
    the information, but preserves the history in the database.

    If the users information is already purged this returns 451. If the
    user does not have demographic information, we create them an all blank
    record and mark it as purged to ensure they cannot submit new information,
    for consistency.
    """
    if authorization is None:
        return Response(status_code=401)

    attempt_request_cost = 5
    success_request_cost = 95
    with LazyItgs(no_read_only=True) as itgs:
        auth = demographics_helper.get_failure_response_or_user_id_and_perms_for_authorization(
            itgs, authorization, attempt_request_cost, req_user_id,
            demographics_helper.PURGE_SELF_DEMOGRAPHICS_PERMISSION,
            demographics_helper.PURGE_OTHERS_DEMOGRAPHICS_PERMISSION, [])

        if isinstance(auth, Response):
            return auth

        (user_id, perms) = auth

        headers = {'x-request-cost': str(attempt_request_cost)}
        if not security.verify_captcha(itgs, captcha):
            return Response(status_code=403, headers=headers)

        headers['x-request-cost'] = str(attempt_request_cost +
                                        success_request_cost)
        if not ratelimit_helper.check_ratelimit(itgs, user_id, perms,
                                                success_request_cost):
            return Response(status_code=429, headers=headers)

        demos = Table('user_demographics')
        itgs.read_cursor.execute(
            Query.from_(demos).select(demos.id, demos.deleted).where(
                demos.user_id == Parameter('%s')).get_sql(), (req_user_id, ))
        row = itgs.read_cursor.fetchone()
        if row is not None and row[1]:
            return Response(status_code=451, headers=headers)

        if row is None:
            users = Table('users')
            itgs.read_cursor.execute(
                Query.from_(users).select(1).where(
                    users.id == Parameter('%s')).limit(1).get_sql(),
                (req_user_id, ))
            if itgs.read_cursor.fetchone() is None:
                return Response(status_code=404, headers=headers)

            itgs.write_cursor.execute(
                Query.into(demos).columns(
                    demos.user_id,
                    demos.deleted).insert(*[Parameter('%s')
                                            for _ in range(2)]).get_sql(),
                (req_user_id, True))
            itgs.write_conn.commit()
            return Response(status_code=200, headers=headers)

        demo_id = row[0]

        demo_history = Table('user_demographic_history')
        itgs.write_cursor.execute(
            Query.update(demo_history).set(demo_history.old_email, None).set(
                demo_history.new_email,
                None).set(demo_history.old_name, None).set(
                    demo_history.new_name,
                    None).set(demo_history.old_street_address, None).set(
                        demo_history.new_street_address,
                        None).set(demo_history.old_city, None).set(
                            demo_history.new_city,
                            None).set(demo_history.old_state, None).set(
                                demo_history.new_state,
                                None).set(demo_history.old_zip, None).set(
                                    demo_history.new_zip, None).set(
                                        demo_history.old_country, None).set(
                                            demo_history.new_country,
                                            None).set(demo_history.purged_at,
                                                      Now()).
            where(
                demo_history.user_demographic_id == Parameter('%s')).get_sql(),
            (demo_id, ))
        itgs.write_cursor.execute(
            Query.into(demo_history).columns(
                demo_history.user_demographic_id,
                demo_history.changed_by_user_id, demo_history.old_deleted,
                demo_history.new_deleted, demo_history.purged_at).insert(
                    *[Parameter('%s') for _ in range(4)], Now()).get_sql(),
            (demo_id, user_id, False, True))
        itgs.write_cursor.execute(
            Query.update(demos).set(demos.email, None).set(
                demos.name, None).set(demos.street_address, None).set(
                    demos.city, None).set(demos.state, None).set(
                        demos.zip, None).set(demos.country, None).set(
                            demos.deleted,
                            True).where(demos.id == Parameter('%s')).get_sql(),
            (demo_id, ))
        itgs.write_conn.commit()
        return Response(status_code=200, headers=headers)
예제 #10
0
def apply_repayment(itgs: LazyItgs, loan_id: int, amount: Money):
    """Applies up to the given amount of money to the given loan. This will
    convert the amount to the loans currency if necessary. This will return the
    primary key of the loan_repayment_events row that was created, the amount
    of money that was applied to the loan (which will be in the loan currency),
    and the amount of money which exceeded the remaining principal to be repaid
    on this loan (which will be in the provided currency).

    This does not commit anything and expects to be running with explicit
    commits, i.e., where this is running in a transaction which it does not
    itself commit.

    For consistency this will use the same conversion rate to USD for the loan
    as when the loan was initially created.

    Example:
        (repayment_event_id, amount_applied, amount_remaining) = apply_repayment(
            itgs, loan_id, amount
        )

    Raises:
        ValueError: If the loan does not exist, is already repaid or, the
          amount is 0.

    Arguments:
        itgs (LazyIntegrations): The lazy loaded networked services connector
        loan_id (int): The primary key of the loan to apply the repayment to
        amount (Money): The amount of money to apply toward this loan. This may
            be in any currency, although it will be converted to the loans
            currency.

    Returns:
        repayment_event_id (int): The primary key of of the loan repayment
            event that this created.
        amount_applied (Money): The amount of money that was applied toward
            the loan, in the loans currency.
        amount_remaining (Money): The amount of money that is remaining, in
            the same currency that the amount was given in.
    """
    tus.check(itgs=(itgs, LazyItgs),
              loan_id=(loan_id, int),
              amount=(amount, Money))
    if amount.minor <= 0:
        raise ValueError(
            f'Cannot apply {amount} toward a loan (only positive amounts can be applied)'
        )

    loans = Table('loans')
    moneys = Table('moneys')
    usrs = Table('users')
    principals = moneys.as_('principals')
    principal_repayments = moneys.as_('principal_repayments')
    currencies = Table('currencies')
    principal_currencies = currencies.as_('principal_currencies')
    lenders = usrs.as_('lenders')
    borrowers = usrs.as_('borrowers')

    itgs.write_cursor.execute(
        Query.from_(loans).select(
            lenders.id, lenders.username, borrowers.id, borrowers.username,
            principal_currencies.id, principal_currencies.code,
            principal_currencies.exponent, principal_currencies.symbol,
            principal_currencies.symbol_on_left, principals.amount,
            principals.amount_usd_cents, principal_repayments.id,
            principal_repayments.amount, loans.unpaid_at).join(principals).
        on(principals.id == loans.principal_id).join(principal_currencies).on(
            principal_currencies.id == principals.currency_id).
        join(principal_repayments).on(
            principal_repayments.id == loans.principal_repayment_id).join(
                lenders).on(lenders.id == loans.lender_id).join(borrowers).on(
                    borrowers.id == loans.borrower_id).where(
                        loans.id == Parameter('%s')).get_sql(), (loan_id, ))
    row = itgs.write_cursor.fetchone()
    if row is None:
        raise ValueError(f'Loan {loan_id} does not exist')

    (lender_user_id, lender_username, borrower_user_id, borrower_username,
     loan_currency_id, loan_currency, loan_currency_exp, loan_currency_symbol,
     loan_currency_symbol_on_left, principal_amount, principal_usd_cents,
     principal_repayment_id, principal_repayment_amount, unpaid_at) = row

    rate_loan_to_usd = (principal_amount / float(principal_usd_cents))

    if principal_amount == principal_repayment_amount:
        raise ValueError(f'Loan {loan_id} is already repaid')

    if loan_currency == amount.currency:
        loan_currency_amount = amount
    else:
        rate_given_to_loan = convert(itgs, amount.currency, loan_currency)
        loan_currency_amount = Money(
            int(math.ceil(amount.minor * rate_given_to_loan)),
            loan_currency,
            exp=loan_currency_exp,
            symbol=loan_currency_symbol,
            symbol_on_left=loan_currency_symbol_on_left)

    applied = Money(min(principal_amount - principal_repayment_amount,
                        loan_currency_amount.minor),
                    loan_currency,
                    exp=loan_currency_exp,
                    symbol=loan_currency_symbol,
                    symbol_on_left=loan_currency_symbol_on_left)
    applied_usd_cents = int(math.ceil(applied.minor / rate_loan_to_usd))

    if loan_currency == amount.currency:
        remaining = Money(amount.minor - applied.minor,
                          loan_currency,
                          exp=loan_currency_exp,
                          symbol=loan_currency_symbol,
                          symbol_on_left=loan_currency_symbol_on_left)
    else:
        applied_in_given_currency = int(
            math.ceil(applied.minor / rate_given_to_loan))
        remaining = Money(max(0, amount.minor - applied_in_given_currency),
                          amount.currency,
                          exp=amount.exp,
                          symbol=amount.symbol,
                          symbol_on_left=amount.symbol_on_left)

    repayment_event_money_id = utils.money_utils.find_or_create_money(
        itgs, applied, applied_usd_cents)

    loan_repayment_events = Table('loan_repayment_events')
    itgs.write_cursor.execute(
        Query.into(loan_repayment_events).columns(
            loan_repayment_events.loan_id,
            loan_repayment_events.repayment_id).insert(
                *[Parameter('%s') for _ in range(2)]).returning(
                    loan_repayment_events.id).get_sql(),
        (loan_id, repayment_event_money_id))
    (repayment_event_id, ) = itgs.write_cursor.fetchone()

    new_princ_repayment_amount = principal_repayment_amount + applied.minor
    new_princ_repayment_usd_cents = int(
        math.ceil(new_princ_repayment_amount / rate_loan_to_usd))
    new_princ_repayment_id = utils.money_utils.find_or_create_money(
        itgs,
        Money(new_princ_repayment_amount,
              loan_currency,
              exp=loan_currency_exp,
              symbol=loan_currency_symbol,
              symbol_on_left=loan_currency_symbol_on_left),
        new_princ_repayment_usd_cents)
    itgs.write_cursor.execute(
        Query.update(loans).set(
            loans.principal_repayment_id,
            Parameter('%s')).where(loans.id == Parameter('%s')).get_sql(),
        (new_princ_repayment_id, loan_id))

    if new_princ_repayment_amount == principal_amount:
        itgs.write_cursor.execute(
            Query.update(loans).set(loans.repaid_at, Now()).set(
                loans.unpaid_at,
                None).where(loans.id == Parameter('%s')).get_sql(),
            (loan_id, ))

        if unpaid_at is not None:
            loan_unpaid_events = Table('loan_unpaid_events')
            itgs.write_cursor.execute(
                Query.into(loan_unpaid_events).columns(
                    loan_unpaid_events.loan_id,
                    loan_unpaid_events.unpaid).insert(
                        *[Parameter('%s') for _ in range(2)]).get_sql(),
                (loan_id, False))

        itgs.channel.exchange_declare('events', 'topic')
        itgs.channel.basic_publish(
            'events', 'loans.paid',
            json.dumps({
                'loan_id': loan_id,
                'lender': {
                    'id': lender_user_id,
                    'username': lender_username
                },
                'borrower': {
                    'id': borrower_user_id,
                    'username': borrower_username
                },
                'amount': {
                    'minor': amount.minor,
                    'currency': amount.currency,
                    'exp': amount.exp,
                    'symbol': amount.symbol,
                    'symbol_on_left': amount.symbol_on_left
                },
                'was_unpaid': unpaid_at is not None
            }))

    return (repayment_event_id, applied, remaining)
예제 #11
0
    def handle_comment(self, itgs, comment, rpiden, rpversion):
        token_vals = PARSER.parse(comment['body'])
        lender_username = comment['author']
        borrower_username = token_vals[0]

        comment_permalink = 'https://www.reddit.com/comments/{}/redditloans/{}'.format(
            comment['link_fullname'][3:], comment['fullname'][3:])

        loans = Table('loans')
        lenders = Table('lenders')
        borrowers = Table('borrowers')
        itgs.write_cursor.execute(
            loan_format_helper.create_loans_query().where(
                lenders.username == Parameter('%s')).where(
                    borrowers.username == Parameter('%s')).where(
                        loans.unpaid_at.isnull()).where(
                            loans.repaid_at.isnull()).get_sql(),
            (lender_username.lower(), borrower_username.lower()))
        row = itgs.write_cursor.fetchone()

        affected_pre = []
        while row is not None:
            affected_pre.append(loan_format_helper.fetch_loan(row))
            row = itgs.write_cursor.fetchone()

        if affected_pre:
            itgs.write_cursor.execute(
                Query.update(loans).set(loans.unpaid_at, Now()).where(
                    loans.id.isin([Parameter('%s')
                                   for _ in affected_pre])).get_sql(),
                tuple(loan.id for loan in affected_pre))

            loan_unpaid_events = Table('loan_unpaid_events')
            itgs.write_cursor.execute(
                Query.into(loan_unpaid_events).columns(
                    loan_unpaid_events.loan_id,
                    loan_unpaid_events.unpaid).insert(
                        *[(Parameter('%s'), True)
                          for _ in affected_pre]).returning(
                              loan_unpaid_events.id).get_sql(),
                tuple(loan.id for loan in affected_pre))
            itgs.channel.exchange_declare('events', 'topic')
            row = itgs.write_cursor.fetchone()
            while row is not None:
                itgs.channel.basic_publish(
                    'events', 'loans.unpaid',
                    json.dumps({"loan_unpaid_event_id": row[0]}))
                row = itgs.write_cursor.fetchone()

            itgs.write_cursor.execute(
                loan_format_helper.create_loans_query().where(
                    loans.id.isin([Parameter('%s')
                                   for _ in affected_pre])).get_sql(),
                tuple(loan.id for loan in affected_pre))
            row = itgs.write_cursor.fetchone()
            affected_post = []
            while row is not None:
                affected_post.append(loan_format_helper.fetch_loan(row))
                row = itgs.write_cursor.fetchone()
        else:
            affected_post = []

        itgs.logger.print(Level.INFO,
                          '/u/{} marked {} loan{} sent to /u/{} unpaid at {}',
                          lender_username, len(affected_pre),
                          's' if len(affected_pre) != 1 else '',
                          borrower_username, comment_permalink)

        borrower_summary = loan_format_helper.get_and_format_all_or_summary(
            itgs, borrower_username)

        formatted_response = get_response(
            itgs,
            'unpaid',
            lender_username=lender_username,
            borrower_username=borrower_username,
            loans_before=loan_format_helper.format_loan_table(affected_pre),
            loans_after=loan_format_helper.format_loan_table(affected_post),
            borrower_summary=borrower_summary)

        utils.reddit_proxy.send_request(itgs, rpiden, rpversion,
                                        'post_comment', {
                                            'parent': comment['fullname'],
                                            'text': formatted_response
                                        })