diff --git a/engineapi/engineapi/actions.py b/engineapi/engineapi/actions.py index acc232e1..23c3e20f 100644 --- a/engineapi/engineapi/actions.py +++ b/engineapi/engineapi/actions.py @@ -1,10 +1,11 @@ from datetime import datetime from collections import Counter -from typing import List, Any, Optional, Dict, Union, Tuple +import json +from typing import List, Any, Optional, Dict, Union, Tuple, cast import uuid import logging -from bugout.data import BugoutResource +from bugout.data import BugoutResource, BugoutSearchResult from eth_typing import Address from hexbytes import HexBytes import requests # type: ignore @@ -15,7 +16,7 @@ from sqlalchemy.engine import Row from web3 import Web3 from web3.types import ChecksumAddress -from .data import Score, LeaderboardScore +from .data import Score, LeaderboardScore, LeaderboardConfigUpdate, LeaderboardConfig from .contracts import Dropper_interface, ERC20_interface, Terminus_interface from .models import ( DropperClaimant, @@ -26,11 +27,12 @@ from .models import ( ) from . import signatures from .settings import ( + bugout_client as bc, BLOCKCHAIN_WEB3_PROVIDERS, LEADERBOARD_RESOURCE_TYPE, MOONSTREAM_APPLICATION_ID, MOONSTREAM_ADMIN_ACCESS_TOKEN, - bugout_client as bc, + MOONSTREAM_LEADERBOARD_CONFIGURATION_JOURNAL_ID, ) @@ -77,6 +79,18 @@ class LeaderboardDeleteError(Exception): pass +class LeaderboardConfigNotFound(Exception): + pass + + +class LeaderboardConfigAlreadyActive(Exception): + pass + + +class LeaderboardConfigAlreadyInactive(Exception): + pass + + BATCH_SIGNATURE_PAGE_SIZE = 500 logger = logging.getLogger(__name__) @@ -1491,6 +1505,130 @@ def list_leaderboards_resources( return query.all() +def get_leaderboard_config_entry( + leaderboard_id: uuid.UUID, +) -> BugoutSearchResult: + query = f"#leaderboard_id:{leaderboard_id}" + configs = bc.search( + token=MOONSTREAM_ADMIN_ACCESS_TOKEN, + journal_id=MOONSTREAM_LEADERBOARD_CONFIGURATION_JOURNAL_ID, + query=query, + limit=1, + ) + + results = cast(List[BugoutSearchResult], configs.results) + + if len(configs.results) == 0 or results[0].content is None: + raise LeaderboardConfigNotFound( + f"Leaderboard config not found for {leaderboard_id}" + ) + + return results[0] + + +def get_leaderboard_config( + leaderboard_id: uuid.UUID, +) -> Dict[str, Any]: + """ + Return leaderboard config from leaderboard generator journal + """ + + entry = get_leaderboard_config_entry(leaderboard_id) + + content = json.loads(entry.content) # type: ignore + + if "status:active" not in entry.tags: + content["leaderboard_auto_update_active"] = False + else: + content["leaderboard_auto_update_active"] = True + + return content + + +def update_leaderboard_config( + leaderboard_id: uuid.UUID, config: LeaderboardConfigUpdate +) -> Dict[str, Any]: + """ + Update leaderboard config in leaderboard generator journal + + """ + + entry_config = get_leaderboard_config_entry(leaderboard_id) + + current_config = LeaderboardConfig(**json.loads(entry_config.content)) # type: ignore + + new_params = config.params + + for key, value in new_params.items(): + if key not in current_config.params: + continue + + current_config.params[key] = value + + # we replace values of parameters that are not None + + entry = bc.update_entry_content( + token=MOONSTREAM_ADMIN_ACCESS_TOKEN, + journal_id=MOONSTREAM_LEADERBOARD_CONFIGURATION_JOURNAL_ID, + title=entry_config.title, + entry_id=entry_config.entry_url.split("/")[-1], + content=json.dumps(current_config.dict()), + ) + + new_config = json.loads(entry.content) + + if "status:active" not in entry.tags: + new_config["leaderboard_auto_update_active"] = False + else: + new_config["leaderboard_auto_update_active"] = True + + return new_config + + +def activate_leaderboard_config( + leaderboard_id: uuid.UUID, +): + """ + Add tag status:active to leaderboard config journal entry + """ + + entry_config = get_leaderboard_config_entry(leaderboard_id) + + if "status:active" in entry_config.tags: + raise LeaderboardConfigAlreadyActive( + f"Leaderboard config {leaderboard_id} already active" + ) + + bc.create_tags( + token=MOONSTREAM_ADMIN_ACCESS_TOKEN, + journal_id=MOONSTREAM_LEADERBOARD_CONFIGURATION_JOURNAL_ID, + entry_id=entry_config.entry_url.split("/")[-1], + tags=["status:active"], + ) + + +def deactivate_leaderboard_config( + leaderboard_id: uuid.UUID, +): + """ + Remove tag status:active from leaderboard config journal entry + """ + + entry_config = get_leaderboard_config_entry(leaderboard_id) + + if "status:active" not in entry_config.tags: + raise LeaderboardConfigAlreadyInactive( + f"Leaderboard config {leaderboard_id} not active" + ) + + bc.delete_tag( + token=MOONSTREAM_ADMIN_ACCESS_TOKEN, + journal_id=MOONSTREAM_LEADERBOARD_CONFIGURATION_JOURNAL_ID, + entry_id=entry_config.entry_url.split("/")[-1], + tag="status:active", + ) + + def revoke_resource( db_session: Session, leaderboard_id: uuid.UUID ) -> Optional[uuid.UUID]: @@ -1529,6 +1667,7 @@ def check_leaderboard_resource_permissions( headers = { "Authorization": f"Bearer {token}", } + # If user don't have at least read permission return 404 result = requests.get(url=permission_url, headers=headers, timeout=10) diff --git a/engineapi/engineapi/data.py b/engineapi/engineapi/data.py index cd92eb04..8a33d619 100644 --- a/engineapi/engineapi/data.py +++ b/engineapi/engineapi/data.py @@ -428,3 +428,17 @@ class LeaderboardDeletedResponse(BaseModel): class LeaderboardScoresChangesResponse(BaseModel): players_count: int date: datetime + + +class LeaderboardConfig(BaseModel): + leaderboard_id: str + leaderboard_auto_update_active: bool = False + query_name: str + params: Dict[str, int] + normalize_addresses: bool + + +class LeaderboardConfigUpdate(BaseModel): + query_name: Optional[str] = None + params: Dict[str, int] + normalize_addresses: Optional[bool] = None diff --git a/engineapi/engineapi/routes/leaderboard.py b/engineapi/engineapi/routes/leaderboard.py index d2a5fbfa..e1d45014 100644 --- a/engineapi/engineapi/routes/leaderboard.py +++ b/engineapi/engineapi/routes/leaderboard.py @@ -1,10 +1,10 @@ """ Leaderboard API. """ -from datetime import datetime import logging from uuid import UUID +from bugout.exceptions import BugoutResponseException from web3 import Web3 from fastapi import FastAPI, Request, Depends, Response, Query, Path, Body, Header from sqlalchemy.orm import Session @@ -668,3 +668,215 @@ async def leaderboard_push_scores( ] return result + + +@app.get( + "/{leaderboard_id}/config", + response_model=data.LeaderboardConfig, + tags=["Authorized Endpoints"], +) +async def leaderboard_config( + request: Request, + leaderboard_id: UUID = Path(..., description="Leaderboard ID"), + db_session: Session = Depends(db.yield_db_session), + Authorization: str = AuthHeader, +) -> data.LeaderboardConfig: + """ + Get leaderboard config. + """ + token = request.state.token + try: + access = actions.check_leaderboard_resource_permissions( + db_session=db_session, + leaderboard_id=leaderboard_id, + token=token, + ) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard not found.", + ) + + if not access: + raise EngineHTTPException( + status_code=403, detail="You don't have access to this leaderboard." + ) + + try: + leaderboard_config = actions.get_leaderboard_config( + leaderboard_id=leaderboard_id, + ) + except BugoutResponseException as e: + raise EngineHTTPException(status_code=e.status_code, detail=e.detail) + except actions.LeaderboardConfigNotFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard config not found.", + ) + except Exception as e: + logger.error(f"Error while getting leaderboard config: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + return data.LeaderboardConfig(**leaderboard_config) + + +@app.put( + "/{leaderboard_id}/config", + response_model=data.LeaderboardConfig, + tags=["Authorized Endpoints"], +) +async def leaderboard_config_update( + request: Request, + leaderboard_id: UUID = Path(..., description="Leaderboard ID"), + config: data.LeaderboardConfigUpdate = Body(..., description="Leaderboard config."), + db_session: Session = Depends(db.yield_db_session), + Authorization: str = AuthHeader, +) -> data.LeaderboardConfig: + """ + Update leaderboard config. + """ + token = request.state.token + try: + access = actions.check_leaderboard_resource_permissions( + db_session=db_session, + leaderboard_id=leaderboard_id, + token=token, + ) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard not found.", + ) + + if not access: + raise EngineHTTPException( + status_code=403, detail="You don't have access to this leaderboard." + ) + + try: + leaderboard_config = actions.update_leaderboard_config( + leaderboard_id=leaderboard_id, + config=config, + ) + except BugoutResponseException as e: + raise EngineHTTPException(status_code=e.status_code, detail=e.detail) + except actions.LeaderboardConfigNotFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard config not found.", + ) + except Exception as e: + logger.error(f"Error while updating leaderboard config: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + return data.LeaderboardConfig(**leaderboard_config) + + +@app.post( + "/{leaderboard_id}/config/activate", + response_model=bool, + tags=["Authorized Endpoints"], +) +async def leaderboard_config_activate( + request: Request, + leaderboard_id: UUID = Path(..., description="Leaderboard ID"), + db_session: Session = Depends(db.yield_db_session), + Authorization: str = AuthHeader, +) -> bool: + """ + Activate leaderboard config. + """ + token = request.state.token + try: + access = actions.check_leaderboard_resource_permissions( + db_session=db_session, + leaderboard_id=leaderboard_id, + token=token, + ) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard not found.", + ) + + if not access: + raise EngineHTTPException( + status_code=403, detail="You don't have access to this leaderboard." + ) + + try: + actions.activate_leaderboard_config( + leaderboard_id=leaderboard_id, + ) + except BugoutResponseException as e: + raise EngineHTTPException(status_code=e.status_code, detail=e.detail) + except actions.LeaderboardConfigNotFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard config not found.", + ) + except actions.LeaderboardConfigAlreadyActive as e: + raise EngineHTTPException( + status_code=409, + detail="Leaderboard config is already active.", + ) + except Exception as e: + logger.error(f"Error while activating leaderboard config: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + return True + + +@app.post( + "/{leaderboard_id}/config/deactivate", + response_model=bool, + tags=["Authorized Endpoints"], +) +async def leaderboard_config_deactivate( + request: Request, + leaderboard_id: UUID = Path(..., description="Leaderboard ID"), + db_session: Session = Depends(db.yield_db_session), + Authorization: str = AuthHeader, +) -> bool: + """ + Deactivate leaderboard config. + """ + token = request.state.token + try: + access = actions.check_leaderboard_resource_permissions( + db_session=db_session, + leaderboard_id=leaderboard_id, + token=token, + ) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard not found.", + ) + + if not access: + raise EngineHTTPException( + status_code=403, detail="You don't have access to this leaderboard." + ) + + try: + actions.deactivate_leaderboard_config( + leaderboard_id=leaderboard_id, + ) + except BugoutResponseException as e: + raise EngineHTTPException(status_code=e.status_code, detail=e.detail) + except actions.LeaderboardConfigNotFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard config not found.", + ) + except actions.LeaderboardConfigAlreadyInactive as e: + raise EngineHTTPException( + status_code=409, + detail="Leaderboard config is already inactive.", + ) + except Exception as e: + logger.error(f"Error while deactivating leaderboard config: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + return True diff --git a/engineapi/engineapi/settings.py b/engineapi/engineapi/settings.py index b58d1a35..02ea9c9b 100644 --- a/engineapi/engineapi/settings.py +++ b/engineapi/engineapi/settings.py @@ -198,3 +198,11 @@ if MOONSTREAM_ADMIN_ACCESS_TOKEN == "": MOONSTREAM_ADMIN_ID = os.environ.get("MOONSTREAM_ADMIN_ID", "") if MOONSTREAM_ADMIN_ID == "": raise ValueError("MOONSTREAM_ADMIN_ID environment variable must be set") + +MOONSTREAM_LEADERBOARD_CONFIGURATION_JOURNAL_ID = os.environ.get( + "MOONSTREAM_LEADERBOARD_CONFIGURATION_JOURNAL_ID", "" +) +if MOONSTREAM_LEADERBOARD_CONFIGURATION_JOURNAL_ID == "": + raise ValueError( + "MOONSTREAM_LEADERBOARD_CONFIGURATION_JOURNAL_ID environment variable must be set" + ) diff --git a/engineapi/engineapi/version.txt b/engineapi/engineapi/version.txt index 1750564f..5a5831ab 100644 --- a/engineapi/engineapi/version.txt +++ b/engineapi/engineapi/version.txt @@ -1 +1 @@ -0.0.6 +0.0.7 diff --git a/engineapi/sample.env b/engineapi/sample.env index b9e921b1..8ed9f111 100644 --- a/engineapi/sample.env +++ b/engineapi/sample.env @@ -19,3 +19,6 @@ export MOONSTREAM_MUMBAI_WEB3_PROVIDER_URI="" export MOONSTREAM_POLYGON_WEB3_PROVIDER_URI="" export MOONSTREAM_XDAI_WEB3_PROVIDER_URI="" export ENGINE_NODEBALANCER_ACCESS_ID="" + +# leaderboard config +export MOONSTREAM_LEADERBOARD_CONFIGURATION_JOURNAL_ID=""