diff --git a/app/model/schema/__init__.py b/app/model/schema/__init__.py index dded9f12..6724c0b8 100644 --- a/app/model/schema/__init__.py +++ b/app/model/schema/__init__.py @@ -113,7 +113,8 @@ from .token_holders import ( CreateTokenHoldersListRequest, CreateTokenHoldersListResponse, - GetTokenHoldersListResponse + RetrieveTokenHoldersListResponse, + ListAllTokenHolderCollectionsResponse ) from .transfer import ( UpdateTransferApprovalRequest, diff --git a/app/model/schema/token_holders.py b/app/model/schema/token_holders.py index 595e564b..96294b3e 100644 --- a/app/model/schema/token_holders.py +++ b/app/model/schema/token_holders.py @@ -20,6 +20,7 @@ from typing import List, Dict, Union from pydantic import BaseModel, Field, validator from app.model.db import TokenHolderBatchStatus +from app.model.schema.types import ResultSet ############################ @@ -70,8 +71,23 @@ class Config: } -class GetTokenHoldersListResponse(BaseModel): - """Get Token Holders List schema (RESPONSE)""" +class RetrieveTokenHolderCollectionResponse(BaseModel): + """Retrieve Token Holders Collection schema (RESPONSE)""" + + token_address: str + block_number: int + list_id: str = Field(description="UUID v4 required") + status: TokenHolderBatchStatus + + +class ListAllTokenHolderCollectionsResponse(BaseModel): + """List All Token Holders Collections schema (RESPONSE)""" + result_set: ResultSet + collections: List[RetrieveTokenHolderCollectionResponse] + + +class RetrieveTokenHoldersListResponse(BaseModel): + """Retrieve Token Holders List schema (RESPONSE)""" status: TokenHolderBatchStatus holders: List[Dict[str, Union[int, str]]] diff --git a/app/routers/token_holders.py b/app/routers/token_holders.py index 3206e4ca..f0800c54 100644 --- a/app/routers/token_holders.py +++ b/app/routers/token_holders.py @@ -17,12 +17,21 @@ SPDX-License-Identifier: Apache-2.0 """ import uuid -from typing import List +from typing import List, Optional -from fastapi import APIRouter, Depends, Header, Path +from fastapi import ( + APIRouter, + Depends, + Header, + Path, + Query +) from fastapi.exceptions import HTTPException from sqlalchemy.orm import Session -from sqlalchemy import asc +from sqlalchemy import ( + asc, + desc +) from web3 import Web3 from web3.middleware import geth_poa_middleware import config @@ -31,11 +40,20 @@ from app.model.schema import ( CreateTokenHoldersListRequest, CreateTokenHoldersListResponse, - GetTokenHoldersListResponse, + RetrieveTokenHoldersListResponse, + ListAllTokenHolderCollectionsResponse ) from app.utils.docs_utils import get_routers_responses -from app.utils.check_utils import validate_headers, address_is_valid_address -from app.model.db import Token, TokenHoldersList, TokenHolderBatchStatus, TokenHolder +from app.utils.check_utils import ( + validate_headers, + address_is_valid_address +) +from app.model.db import ( + Token, + TokenHoldersList, + TokenHolderBatchStatus, + TokenHolder +) from app.exceptions import InvalidParameterError web3 = Web3(Web3.HTTPProvider(config.WEB3_HTTP_PROVIDER)) @@ -123,13 +141,96 @@ def create_collection( } +# GET: /token/holders/{token_address}/collection +@router.get( + "/holders/{token_address}/collection", + response_model=ListAllTokenHolderCollectionsResponse, + responses=get_routers_responses(422, 404, InvalidParameterError) +) +def list_all_token_holders_collections( + token_address: str = Path(...), + issuer_address: Optional[str] = Header(None), + status: Optional[TokenHolderBatchStatus] = Query(None), + sort_order: int = Query(1, ge=0, le=1, description="0:asc, 1:desc (created)"), + offset: Optional[int] = Query(None), + limit: Optional[int] = Query(None), + db: Session = Depends(db_session) +): + # Validate Headers + validate_headers(issuer_address=(issuer_address, address_is_valid_address)) + + # Get Token to ensure input token valid + query = ( + db.query(Token) + .filter(Token.token_address == token_address) + .filter(Token.token_status != 2) + ) + + if issuer_address is not None: + query = query.filter(Token.issuer_address == issuer_address) + + _token = query.first() + if _token is None: + raise HTTPException(status_code=404, detail="token not found") + if _token.token_status == 0: + raise InvalidParameterError("this token is temporarily unavailable") + + query = db.query(TokenHoldersList).filter(TokenHoldersList.token_address == token_address) + + # Total + total = query.count() + if status is not None: + query = query.filter(TokenHoldersList.batch_status == status.value) + + # Sort + if sort_order == 0: # ASC + query = query.order_by(TokenHoldersList.created) + else: # DESC + query = query.order_by(desc(TokenHoldersList.created)) + + # Count + count = query.count() + + # Pagination + if limit is not None: + query = query.limit(limit) + if offset is not None: + query = query.offset(offset) + + # Get all collections + _token_holders_collections: list[TokenHoldersList] = query.all() + + token_holders_collections = [] + for _collection in _token_holders_collections: + token_holders_collections.append( + { + "token_address": _collection.token_address, + "block_number": _collection.block_number, + "list_id": _collection.list_id, + "status": _collection.batch_status + } + ) + + resp = { + "result_set": { + "count": count, + "offset": offset, + "limit": limit, + "total": total + }, + "collections": token_holders_collections + } + + return resp + + # GET: /token/holders/{token_address}/collection/{list_id} @router.get( "/holders/{token_address}/collection/{list_id}", - response_model=GetTokenHoldersListResponse, + response_model=RetrieveTokenHoldersListResponse, responses=get_routers_responses(404, InvalidParameterError), ) -def get_token_holders( +def retrieve_token_holders_list( token_address: str = Path(...), list_id: str = Path( ..., diff --git a/tests/test_app_routers_token_holders_{token_address}_GET.py b/tests/test_app_routers_token_holders_{token_address}_GET.py new file mode 100644 index 00000000..68bc52fd --- /dev/null +++ b/tests/test_app_routers_token_holders_{token_address}_GET.py @@ -0,0 +1,673 @@ +""" +Copyright BOOSTRY Co., Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. + +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, +software distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + +See the License for the specific language governing permissions and +limitations under the License. + +SPDX-License-Identifier: Apache-2.0 +""" +import uuid +from unittest import mock + +from app.model.db import ( + Token, + TokenType, + TokenHoldersList, + TokenHolderBatchStatus +) +from tests.account_config import config_eth_account + + +class TestAppRoutersTokenHoldersGET: + # target API endpoint + base_url = "/token/holders/{}/collection" + + ########################################################################### + # Normal Case + ########################################################################### + + # + # 0 record + def test_normal_1(self, client, db): + issuer_account = config_eth_account("user1") + issuer_address = issuer_account["address"] + token_address = "token_address_test" + + # prepare data + token = Token() + token.type = TokenType.IBET_STRAIGHT_BOND.value + token.tx_hash = "" + token.issuer_address = issuer_address + token.token_address = token_address + token.abi = "" + db.add(token) + + # request target API + resp = client.get( + self.base_url.format(token_address), + headers={} + ) + + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 0, + "limit": None, + "offset": None, + "total": 0 + }, + "collections": [] + } + + # + # 1 record + def test_normal_2(self, client, db): + issuer_account = config_eth_account("user1") + issuer_address = issuer_account["address"] + token_address = "token_address_test" + + # prepare data + token = Token() + token.type = TokenType.IBET_STRAIGHT_BOND.value + token.tx_hash = "" + token.issuer_address = issuer_address + token.token_address = token_address + token.abi = "" + db.add(token) + + token_holder_list1 = TokenHoldersList() + token_holder_list1.token_address = token_address + token_holder_list1.list_id = str(uuid.uuid4()) + token_holder_list1.block_number = 100 + token_holder_list1.batch_status = TokenHolderBatchStatus.PENDING.value + db.add(token_holder_list1) + + # request target API + resp = client.get( + self.base_url.format(token_address), + headers={} + ) + + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 1, + "limit": None, + "offset": None, + "total": 1 + }, + "collections": [ + { + "token_address": token_address, + "block_number": 100, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.PENDING.value + } + ] + } + + # + # Multi record + def test_normal_3_1(self, client, db): + issuer_account = config_eth_account("user1") + issuer_address = issuer_account["address"] + token_address = "token_address_test" + + # prepare data + + token = Token() + token.type = TokenType.IBET_STRAIGHT_BOND.value + token.tx_hash = "" + token.issuer_address = issuer_address + token.token_address = token_address + token.abi = "" + db.add(token) + + token_holder_list1 = TokenHoldersList() + token_holder_list1.token_address = token_address + token_holder_list1.list_id = str(uuid.uuid4()) + token_holder_list1.block_number = 100 + token_holder_list1.batch_status = TokenHolderBatchStatus.PENDING.value + db.add(token_holder_list1) + + token_holder_list2 = TokenHoldersList() + token_holder_list2.token_address = token_address + token_holder_list2.list_id = str(uuid.uuid4()) + token_holder_list2.block_number = 200 + token_holder_list2.batch_status = TokenHolderBatchStatus.FAILED.value + db.add(token_holder_list2) + + token_holder_list3 = TokenHoldersList() + token_holder_list3.token_address = token_address + token_holder_list3.list_id = str(uuid.uuid4()) + token_holder_list3.block_number = 300 + token_holder_list3.batch_status = TokenHolderBatchStatus.FAILED.value + db.add(token_holder_list3) + + token_holder_list4 = TokenHoldersList() + token_holder_list4.token_address = token_address + token_holder_list4.list_id = str(uuid.uuid4()) + token_holder_list4.block_number = 400 + token_holder_list4.batch_status = TokenHolderBatchStatus.DONE.value + db.add(token_holder_list4) + + token_holder_list5 = TokenHoldersList() + token_holder_list5.token_address = token_address + token_holder_list5.list_id = str(uuid.uuid4()) + token_holder_list5.block_number = 500 + token_holder_list5.batch_status = TokenHolderBatchStatus.DONE.value + db.add(token_holder_list5) + + # request target API + resp = client.get( + self.base_url.format(token_address), + headers={} + ) + + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 5, + "limit": None, + "offset": None, + "total": 5 + }, + "collections": [ + { + "token_address": token_address, + "block_number": 500, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.DONE.value + }, + { + "token_address": token_address, + "block_number": 400, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.DONE.value + }, + { + "token_address": token_address, + "block_number": 300, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.FAILED.value + }, + { + "token_address": token_address, + "block_number": 200, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.FAILED.value + }, + { + "token_address": token_address, + "block_number": 100, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.PENDING.value + } + ] + } + + # + # Multi record (Issuer specified) + def test_normal_3_2(self, client, db): + issuer_account = config_eth_account("user1") + issuer_address = issuer_account["address"] + token_address = "token_address_test" + + # prepare data + token = Token() + token.type = TokenType.IBET_STRAIGHT_BOND.value + token.tx_hash = "" + token.issuer_address = issuer_address + token.token_address = token_address + token.abi = "" + db.add(token) + + token_holder_list1 = TokenHoldersList() + token_holder_list1.token_address = token_address + token_holder_list1.list_id = str(uuid.uuid4()) + token_holder_list1.block_number = 100 + token_holder_list1.batch_status = TokenHolderBatchStatus.PENDING.value + db.add(token_holder_list1) + + token_holder_list2 = TokenHoldersList() + token_holder_list2.token_address = token_address + token_holder_list2.list_id = str(uuid.uuid4()) + token_holder_list2.block_number = 200 + token_holder_list2.batch_status = TokenHolderBatchStatus.FAILED.value + db.add(token_holder_list2) + + token_holder_list3 = TokenHoldersList() + token_holder_list3.token_address = token_address + token_holder_list3.list_id = str(uuid.uuid4()) + token_holder_list3.block_number = 300 + token_holder_list3.batch_status = TokenHolderBatchStatus.FAILED.value + db.add(token_holder_list3) + + token_holder_list4 = TokenHoldersList() + token_holder_list4.token_address = token_address + token_holder_list4.list_id = str(uuid.uuid4()) + token_holder_list4.block_number = 400 + token_holder_list4.batch_status = TokenHolderBatchStatus.DONE.value + db.add(token_holder_list4) + + token_holder_list5 = TokenHoldersList() + token_holder_list5.token_address = token_address + token_holder_list5.list_id = str(uuid.uuid4()) + token_holder_list5.block_number = 500 + token_holder_list5.batch_status = TokenHolderBatchStatus.DONE.value + db.add(token_holder_list5) + + # request target API + resp = client.get( + self.base_url.format(token_address), + headers={ + "issuer-address": issuer_address + } + ) + + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 5, + "limit": None, + "offset": None, + "total": 5 + }, + "collections": [ + { + "token_address": token_address, + "block_number": 500, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.DONE.value + }, + { + "token_address": token_address, + "block_number": 400, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.DONE.value + }, + { + "token_address": token_address, + "block_number": 300, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.FAILED.value + }, + { + "token_address": token_address, + "block_number": 200, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.FAILED.value + }, + { + "token_address": token_address, + "block_number": 100, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.PENDING.value + } + ] + } + + # + # filter by status + def test_normal_3_3(self, client, db): + issuer_account = config_eth_account("user1") + issuer_address = issuer_account["address"] + token_address = "token_address_test" + + # prepare data + token = Token() + token.type = TokenType.IBET_STRAIGHT_BOND.value + token.tx_hash = "" + token.issuer_address = issuer_address + token.token_address = token_address + token.abi = "" + db.add(token) + + token_holder_list1 = TokenHoldersList() + token_holder_list1.token_address = token_address + token_holder_list1.list_id = str(uuid.uuid4()) + token_holder_list1.block_number = 100 + token_holder_list1.batch_status = TokenHolderBatchStatus.PENDING.value + db.add(token_holder_list1) + + token_holder_list2 = TokenHoldersList() + token_holder_list2.token_address = token_address + token_holder_list2.list_id = str(uuid.uuid4()) + token_holder_list2.block_number = 200 + token_holder_list2.batch_status = TokenHolderBatchStatus.FAILED.value + db.add(token_holder_list2) + + token_holder_list3 = TokenHoldersList() + token_holder_list3.token_address = token_address + token_holder_list3.list_id = str(uuid.uuid4()) + token_holder_list3.block_number = 300 + token_holder_list3.batch_status = TokenHolderBatchStatus.FAILED.value + db.add(token_holder_list3) + + token_holder_list4 = TokenHoldersList() + token_holder_list4.token_address = token_address + token_holder_list4.list_id = str(uuid.uuid4()) + token_holder_list4.block_number = 400 + token_holder_list4.batch_status = TokenHolderBatchStatus.DONE.value + db.add(token_holder_list4) + + token_holder_list5 = TokenHoldersList() + token_holder_list5.token_address = token_address + token_holder_list5.list_id = str(uuid.uuid4()) + token_holder_list5.block_number = 500 + token_holder_list5.batch_status = TokenHolderBatchStatus.DONE.value + db.add(token_holder_list5) + + # request target API + resp = client.get( + self.base_url.format(token_address), + headers={ + "issuer-address": issuer_address + }, + params={ + "status": str(TokenHolderBatchStatus.PENDING.value) + } + ) + + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 1, + "limit": None, + "offset": None, + "total": 5 + }, + "collections": [ + { + "token_address": token_address, + "block_number": 100, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.PENDING.value + } + ] + } + + # + # Pagination + def test_normal_4(self, client, db): + issuer_account = config_eth_account("user1") + issuer_address = issuer_account["address"] + token_address = "token_address_test" + + # prepare data + token = Token() + token.type = TokenType.IBET_STRAIGHT_BOND.value + token.tx_hash = "" + token.issuer_address = issuer_address + token.token_address = token_address + token.abi = "" + db.add(token) + + token_holder_list1 = TokenHoldersList() + token_holder_list1.token_address = token_address + token_holder_list1.list_id = str(uuid.uuid4()) + token_holder_list1.block_number = 100 + token_holder_list1.batch_status = TokenHolderBatchStatus.PENDING.value + db.add(token_holder_list1) + + token_holder_list2 = TokenHoldersList() + token_holder_list2.token_address = token_address + token_holder_list2.list_id = str(uuid.uuid4()) + token_holder_list2.block_number = 200 + token_holder_list2.batch_status = TokenHolderBatchStatus.FAILED.value + db.add(token_holder_list2) + + token_holder_list3 = TokenHoldersList() + token_holder_list3.token_address = token_address + token_holder_list3.list_id = str(uuid.uuid4()) + token_holder_list3.block_number = 300 + token_holder_list3.batch_status = TokenHolderBatchStatus.FAILED.value + db.add(token_holder_list3) + + token_holder_list4 = TokenHoldersList() + token_holder_list4.token_address = token_address + token_holder_list4.list_id = str(uuid.uuid4()) + token_holder_list4.block_number = 400 + token_holder_list4.batch_status = TokenHolderBatchStatus.DONE.value + db.add(token_holder_list4) + + token_holder_list5 = TokenHoldersList() + token_holder_list5.token_address = token_address + token_holder_list5.list_id = str(uuid.uuid4()) + token_holder_list5.block_number = 500 + token_holder_list5.batch_status = TokenHolderBatchStatus.DONE.value + db.add(token_holder_list5) + + # request target API + req_param = { + "limit": 2, + "offset": 2 + } + resp = client.get( + self.base_url.format(token_address), + params=req_param + ) + + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 5, + "limit": 2, + "offset": 2, + "total": 5 + }, + "collections": [ + { + "token_address": token_address, + "block_number": 300, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.FAILED.value + }, + { + "token_address": token_address, + "block_number": 200, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.FAILED.value + } + ] + } + + # + # Sort + def test_normal_5(self, client, db): + issuer_account = config_eth_account("user1") + issuer_address = issuer_account["address"] + token_address = "token_address_test" + + # prepare data + token = Token() + token.type = TokenType.IBET_STRAIGHT_BOND.value + token.tx_hash = "" + token.issuer_address = issuer_address + token.token_address = token_address + token.abi = "" + db.add(token) + + token_holder_list1 = TokenHoldersList() + token_holder_list1.token_address = token_address + token_holder_list1.list_id = str(uuid.uuid4()) + token_holder_list1.block_number = 100 + token_holder_list1.batch_status = TokenHolderBatchStatus.PENDING.value + db.add(token_holder_list1) + + token_holder_list2 = TokenHoldersList() + token_holder_list2.token_address = token_address + token_holder_list2.list_id = str(uuid.uuid4()) + token_holder_list2.block_number = 200 + token_holder_list2.batch_status = TokenHolderBatchStatus.FAILED.value + db.add(token_holder_list2) + + token_holder_list3 = TokenHoldersList() + token_holder_list3.token_address = token_address + token_holder_list3.list_id = str(uuid.uuid4()) + token_holder_list3.block_number = 300 + token_holder_list3.batch_status = TokenHolderBatchStatus.FAILED.value + db.add(token_holder_list3) + + token_holder_list4 = TokenHoldersList() + token_holder_list4.token_address = token_address + token_holder_list4.list_id = str(uuid.uuid4()) + token_holder_list4.block_number = 400 + token_holder_list4.batch_status = TokenHolderBatchStatus.DONE.value + db.add(token_holder_list4) + + token_holder_list5 = TokenHoldersList() + token_holder_list5.token_address = token_address + token_holder_list5.list_id = str(uuid.uuid4()) + token_holder_list5.block_number = 500 + token_holder_list5.batch_status = TokenHolderBatchStatus.DONE.value + db.add(token_holder_list5) + + # request target API + req_param = { + "sort_order": 0 + } + resp = client.get( + self.base_url.format(token_address), + params=req_param + ) + + assert resp.status_code == 200 + assert resp.json() == { + "result_set": { + "count": 5, + "limit": None, + "offset": None, + "total": 5 + }, + "collections": [ + { + "token_address": token_address, + "block_number": 100, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.PENDING.value + }, + { + "token_address": token_address, + "block_number": 200, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.FAILED.value + }, + { + "token_address": token_address, + "block_number": 300, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.FAILED.value + }, + { + "token_address": token_address, + "block_number": 400, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.DONE.value + }, + { + "token_address": token_address, + "block_number": 500, + "list_id": mock.ANY, + "status": TokenHolderBatchStatus.DONE.value + } + ] + } + + ########################################################################### + # Error Case + ########################################################################### + + # + # Parameter Error + def test_error_1(self, client, db): + # request target API + resp = client.get( + self.base_url, + headers={ + "issuer-address": "test", + } + ) + + # assertion + assert resp.status_code == 422 + assert resp.json() == { + "meta": { + "code": 1, + "title": "RequestValidationError" + }, + "detail": [ + { + "loc": ["header", "issuer-address"], + "msg": "issuer-address is not a valid address", + "type": "value_error" + } + ] + } + + # + # Token Not Found + def test_error_2(self, client, db): + issuer_account = config_eth_account("user1") + issuer_address = issuer_account["address"] + + # request target API + resp = client.get( + self.base_url.format("invalid_address"), + headers={ + "issuer-address": issuer_address + } + ) + + # assertion + assert resp.status_code == 404 + assert resp.json() == { + "detail": "token not found", + "meta": { + "code": 1, "title": "NotFound" + } + } + + # + # Token status pending + def test_error_3(self, client, db): + issuer_account = config_eth_account("user1") + issuer_address = issuer_account["address"] + token_address = "token_address_test" + + # prepare data + token = Token() + token.type = TokenType.IBET_STRAIGHT_BOND.value + token.tx_hash = "" + token.issuer_address = issuer_address + token.token_address = token_address + token.token_status = 0 + token.abi = "" + db.add(token) + + # request target API + resp = client.get( + self.base_url.format(token_address), + headers={ + "issuer-address": issuer_address + } + ) + + # assertion + assert resp.status_code == 400 + assert resp.json() == { + "detail": "this token is temporarily unavailable", + "meta": { + "code": 1, "title": "InvalidParameterError" + } + }