diff --git a/engineapi/README.md b/engineapi/README.md index 77e6257c..5c9c0c37 100644 --- a/engineapi/README.md +++ b/engineapi/README.md @@ -1,43 +1,9 @@ -# lootbox +## `client` -Use lootboxes in your game economy with ready to use contracts +This repository contains a lightweight Python client for the Engine API. -## Deployment - -Deployment with local signer server +To use, for example, with Leaderboard API: ```bash -MOONSTREAM_SIGNING_SERVER_IP=127.0.0.1 ./dev.sh -``` - -## Run frontend - -Do from root directory workspace directory: - -Engine: - -Run dev - -``` -yarn workspace engine run dev -``` - -Build - -``` -yarn workspace engine run build -``` - -Player: - -Run dev - -``` -yarn workspace player run dev -``` - -Build - -``` -yarn workspace player run build +python -m client.leaderboards -h ``` diff --git a/engineapi/alembic/versions/cc80e886e153_added_leaderboard_versions_table_and_.py b/engineapi/alembic/versions/cc80e886e153_added_leaderboard_versions_table_and_.py new file mode 100644 index 00000000..b6dff83e --- /dev/null +++ b/engineapi/alembic/versions/cc80e886e153_added_leaderboard_versions_table_and_.py @@ -0,0 +1,154 @@ +"""Added leaderboard_versions table and corresponding constraints + +Revision ID: cc80e886e153 +Revises: 040f2dfde5a5 +Create Date: 2023-11-08 16:16:39.265150 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "cc80e886e153" +down_revision = "040f2dfde5a5" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "leaderboard_versions", + sa.Column("leaderboard_id", sa.UUID(), nullable=False), + sa.Column("version_number", sa.DECIMAL(), nullable=False), + sa.Column("published", sa.Boolean(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("TIMEZONE('utc', statement_timestamp())"), + nullable=False, + ), + sa.ForeignKeyConstraint( + ["leaderboard_id"], + ["leaderboards.id"], + name=op.f("fk_leaderboard_versions_leaderboard_id_leaderboards"), + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint( + "leaderboard_id", "version_number", name=op.f("pk_leaderboard_versions") + ), + sa.UniqueConstraint( + "leaderboard_id", + "version_number", + name=op.f("uq_leaderboard_versions_leaderboard_id"), + ), + ) + op.create_index( + op.f("ix_leaderboard_versions_created_at"), + "leaderboard_versions", + ["created_at"], + unique=False, + ) + op.add_column( + "leaderboard_scores", + sa.Column("leaderboard_version_number", sa.DECIMAL(), nullable=True), + ) + op.drop_constraint( + "uq_leaderboard_scores_leaderboard_id", "leaderboard_scores", type_="unique" + ) + op.create_unique_constraint( + op.f("uq_leaderboard_scores_leaderboard_id"), + "leaderboard_scores", + ["leaderboard_id", "address", "leaderboard_version_number"], + ) + op.drop_constraint( + "fk_leaderboard_scores_leaderboard_id_leaderboards", + "leaderboard_scores", + type_="foreignkey", + ) + op.create_foreign_key( + op.f("fk_leaderboard_scores_leaderboard_id_leaderboard_versions"), + "leaderboard_scores", + "leaderboard_versions", + ["leaderboard_id", "leaderboard_version_number"], + ["leaderboard_id", "version_number"], + ondelete="CASCADE", + ) + # ### end Alembic commands ### + + # Insert version 0 for all existing leaderboards + op.execute( + """ + INSERT INTO leaderboard_versions (leaderboard_id, version_number, published) + SELECT id, 0, true FROM leaderboards + """ + ) + # Set the leaderboard_version_number for all existing scores to the version 0 + op.execute( + """ + UPDATE leaderboard_scores SET leaderboard_version_number = 0 + """ + ) + # Alter leaderboard_scores to make leaderboard_version_number non-nullable + op.alter_column( + "leaderboard_scores", + "leaderboard_version_number", + nullable=False, + ) + + +def downgrade(): + op.execute( + """ + WITH latest_version_for_leaderboard AS ( + SELECT leaderboard_id, MAX(version_number) AS latest_version + FROM leaderboard_versions WHERE published = true + GROUP BY leaderboard_id + ) + DELETE FROM leaderboard_scores WHERE + (leaderboard_id, leaderboard_version_number) NOT IN ( + SELECT + leaderboard_id, + latest_version AS leaderboard_version_number + FROM + latest_version_for_leaderboard + ) + """ + ) + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint( + op.f("fk_leaderboard_scores_leaderboard_id_leaderboard_versions"), + "leaderboard_scores", + type_="foreignkey", + ) + op.create_foreign_key( + "fk_leaderboard_scores_leaderboard_id_leaderboards", + "leaderboard_scores", + "leaderboards", + ["leaderboard_id"], + ["id"], + ondelete="CASCADE", + ) + op.drop_constraint( + op.f("uq_leaderboard_scores_leaderboard_id"), + "leaderboard_scores", + type_="unique", + ) + op.create_unique_constraint( + "uq_leaderboard_scores_leaderboard_id", + "leaderboard_scores", + ["leaderboard_id", "address"], + ) + op.drop_column("leaderboard_scores", "leaderboard_version_number") + op.drop_index( + op.f("ix_leaderboard_versions_created_at"), table_name="leaderboard_versions" + ) + op.drop_table("leaderboard_versions") + # ### end Alembic commands ### diff --git a/engineapi/client/__init__.py b/engineapi/client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/engineapi/client/leaderboards.py b/engineapi/client/leaderboards.py new file mode 100644 index 00000000..59e439ed --- /dev/null +++ b/engineapi/client/leaderboards.py @@ -0,0 +1,234 @@ +import argparse +import json +import os +import sys +from typing import Optional +import uuid + +import requests + +LEADERBOARD_API_URL = os.environ.get( + "LEADERBOARD_API_URL", "http://localhost:7191/leaderboard/" +) + + +def moonstream_access_token(value: Optional[str]) -> uuid.UUID: + if value is None: + value = os.environ.get("MOONSTREAM_ACCESS_TOKEN") + + if value is None: + raise ValueError( + "Moonstream access token is required: either via -A/--authorization, or via the MOONSTREAM_ACCESS_TOKEN environment variable" + ) + + try: + value_uuid = uuid.UUID(value) + except Exception: + raise ValueError("Moonstream access token must be a valid UUID") + + return value_uuid + + +def requires_authorization(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "-A", + "--authorization", + type=moonstream_access_token, + required=False, + default=os.environ.get("MOONSTREAM_ACCESS_TOKEN"), + help="Moonstream API access token (if not provided, must be specified using the MOONSTREAM_ACCESS_TOKEN environment variable)", + ) + + +def handle_get(args: argparse.Namespace) -> None: + url = LEADERBOARD_API_URL + params = { + "leaderboard_id": str(args.id), + "limit": str(args.limit), + "offset": str(args.offset), + } + if args.version is not None: + params["version"] = str(args.version) + + response = requests.get(url, params=params) + response.raise_for_status() + + print(json.dumps(response.json())) + + +def handle_create(args: argparse.Namespace) -> None: + url = LEADERBOARD_API_URL + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {str(args.authorization)}", + } + + body = { + "title": args.title, + "description": args.description, + } + + response = requests.post(url, headers=headers, json=body) + response.raise_for_status() + print(json.dumps(response.json())) + + +def handle_versions(args: argparse.Namespace) -> None: + url = f"{LEADERBOARD_API_URL}{args.id}/versions" + + headers = { + "Authorization": f"Bearer {str(args.authorization)}", + } + + response = requests.get(url, headers=headers) + response.raise_for_status() + print(json.dumps(response.json())) + + +def handle_create_version(args: argparse.Namespace) -> None: + url = f"{LEADERBOARD_API_URL}{args.id}/versions" + + headers = { + "Authorization": f"Bearer {str(args.authorization)}", + "Content-Type": "application/json", + } + + body = { + "publish": args.publish, + } + + response = requests.post(url, headers=headers, json=body) + response.raise_for_status() + print(json.dumps(response.json())) + + +def handle_publish(args: argparse.Namespace) -> None: + url = f"{LEADERBOARD_API_URL}{args.id}/versions/{args.version}" + + headers = { + "Authorization": f"Bearer {str(args.authorization)}", + "Content-Type": "application/json", + } + + body = { + "publish": args.publish, + } + + response = requests.put(url, headers=headers, json=body) + response.raise_for_status() + print(json.dumps(response.json())) + + +def handle_upload_scores(args: argparse.Namespace) -> None: + url = f"{LEADERBOARD_API_URL}{args.id}/scores" + if args.version is not None: + url = f"{LEADERBOARD_API_URL}{args.id}/versions/{args.version}/scores" + + params = { + "overwrite": "true", + "normalize_addresses": "false", + } + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {str(args.authorization)}", + } + + if args.scores is None: + args.scores = sys.stdin + + with args.scores as ifp: + body = json.load(ifp) + + response = requests.put(url, headers=headers, params=params, json=body) + response.raise_for_status() + print(json.dumps(response.json())) + + +def generate_cli() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="HTTP client for Leaderboard API") + parser.set_defaults(func=lambda _: parser.print_help()) + + subparsers = parser.add_subparsers() + + # GET /leaderboard/?leaderboard_id=&limit=&offset=&version= + get_parser = subparsers.add_parser("get") + get_parser.add_argument("-i", "--id", type=uuid.UUID, required=True) + get_parser.add_argument("-l", "--limit", type=int, default=10) + get_parser.add_argument("-o", "--offset", type=int, default=0) + get_parser.add_argument("-v", "--version", type=int, default=None) + get_parser.set_defaults(func=handle_get) + + # POST /leaderboard/ + create_parser = subparsers.add_parser("create") + create_parser.add_argument( + "-t", "--title", type=str, required=True, help="Title for leaderboard" + ) + create_parser.add_argument( + "-d", + "--description", + type=str, + required=False, + default="", + help="Description for leaderboard", + ) + requires_authorization(create_parser) + create_parser.set_defaults(func=handle_create) + + # GET /leaderboard//versions + versions_parser = subparsers.add_parser("versions") + versions_parser.add_argument("-i", "--id", type=uuid.UUID, required=True) + requires_authorization(versions_parser) + versions_parser.set_defaults(func=handle_versions) + + # POST /leaderboard//versions + create_version_parser = subparsers.add_parser("create-version") + create_version_parser.add_argument("-i", "--id", type=uuid.UUID, required=True) + create_version_parser.add_argument( + "--publish", + action="store_true", + help="Set this flag to publish the version immediately upon creation", + ) + requires_authorization(create_version_parser) + create_version_parser.set_defaults(func=handle_create_version) + + # PUT /leaderboard//versions/ + publish_parser = subparsers.add_parser("publish") + publish_parser.add_argument("-i", "--id", type=uuid.UUID, required=True) + publish_parser.add_argument("-v", "--version", type=int, required=True) + publish_parser.add_argument( + "--publish", action="store_true", help="Set to publish, leave to unpublish" + ) + requires_authorization(publish_parser) + publish_parser.set_defaults(func=handle_publish) + + # PUT /leaderboard//scores and PUT /leaderboard//versions//scores + upload_scores_parser = subparsers.add_parser("upload-scores") + upload_scores_parser.add_argument("-i", "--id", type=uuid.UUID, required=True) + upload_scores_parser.add_argument( + "-v", + "--version", + type=int, + required=False, + default=None, + help="Specify a version to upload scores to (if not specified a new version is created)", + ) + upload_scores_parser.add_argument( + "-s", + "--scores", + type=argparse.FileType("r"), + required=False, + default=None, + help="Path to scores file. If not provided, reads from stdin.", + ) + upload_scores_parser.set_defaults(func=handle_upload_scores) + requires_authorization(upload_scores_parser) + + return parser + + +if __name__ == "__main__": + parser = generate_cli() + args = parser.parse_args() + args.func(args) diff --git a/engineapi/client/sample-score.json b/engineapi/client/sample-score.json new file mode 100644 index 00000000..3523a324 --- /dev/null +++ b/engineapi/client/sample-score.json @@ -0,0 +1,10 @@ +[ + { + "address": "0x0000000000000000000000000000000000000000", + "score": 19, + "points_data": { + "secondary_score_1": 7, + "secondary_score_2": 29 + } + } +] diff --git a/engineapi/engineapi/actions.py b/engineapi/engineapi/actions.py index 23c3e20f..ef7f79b6 100644 --- a/engineapi/engineapi/actions.py +++ b/engineapi/engineapi/actions.py @@ -11,7 +11,7 @@ from hexbytes import HexBytes import requests # type: ignore from sqlalchemy.dialects.postgresql import insert from sqlalchemy.orm import Session -from sqlalchemy import func, text, or_ +from sqlalchemy import func, text, or_, and_, Subquery from sqlalchemy.engine import Row from web3 import Web3 from web3.types import ChecksumAddress @@ -24,6 +24,7 @@ from .models import ( DropperClaim, Leaderboard, LeaderboardScores, + LeaderboardVersion, ) from . import signatures from .settings import ( @@ -91,6 +92,10 @@ class LeaderboardConfigAlreadyInactive(Exception): pass +class LeaderboardVersionNotFound(Exception): + pass + + BATCH_SIGNATURE_PAGE_SIZE = 500 logger = logging.getLogger(__name__) @@ -959,24 +964,71 @@ def refetch_drop_signatures( return claimant_objects -def get_leaderboard_total_count(db_session: Session, leaderboard_id) -> int: +def leaderboard_version_filter( + db_session: Session, + leaderboard_id: uuid.UUID, + version_number: Optional[int] = None, +) -> Union[Subquery, int]: + # Subquery to get the latest version number for the given leaderboard + if version_number is None: + latest_version = ( + db_session.query(func.max(LeaderboardVersion.version_number)).filter( + LeaderboardVersion.leaderboard_id == leaderboard_id, + LeaderboardVersion.published == True, + ) + ).scalar_subquery() + else: + latest_version = version_number + + return latest_version + + +def get_leaderboard_total_count( + db_session: Session, leaderboard_id, version_number: Optional[int] = None +) -> int: """ - Get the total number of claimants in the leaderboard + Get the total number of position in the leaderboard """ - return ( - db_session.query(LeaderboardScores) - .filter(LeaderboardScores.leaderboard_id == leaderboard_id) - .count() + + latest_version = leaderboard_version_filter( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=version_number, ) + total_count = ( + db_session.query(func.count(LeaderboardScores.id)) + .join( + LeaderboardVersion, + and_( + LeaderboardVersion.leaderboard_id == LeaderboardScores.leaderboard_id, + LeaderboardVersion.version_number + == LeaderboardScores.leaderboard_version_number, + ), + ) + .filter( + LeaderboardVersion.published == True, + LeaderboardVersion.version_number == latest_version, + ) + .filter(LeaderboardScores.leaderboard_id == leaderboard_id) + ).scalar() + + return total_count + def get_leaderboard_info( - db_session: Session, leaderboard_id: uuid.UUID + db_session: Session, leaderboard_id: uuid.UUID, version_number: Optional[int] = None ) -> Row[Tuple[uuid.UUID, str, str, int, Optional[datetime]]]: """ Get the leaderboard from the database with users count """ + latest_version = leaderboard_version_filter( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=version_number, + ) + leaderboard = ( db_session.query( Leaderboard.id, @@ -990,10 +1042,22 @@ def get_leaderboard_info( LeaderboardScores.leaderboard_id == Leaderboard.id, isouter=True, ) + .join( + LeaderboardVersion, + and_( + LeaderboardVersion.leaderboard_id == LeaderboardScores.leaderboard_id, + LeaderboardVersion.version_number + == LeaderboardScores.leaderboard_version_number, + ), + isouter=True, + ) + .filter( + LeaderboardVersion.published == True, + LeaderboardVersion.version_number == latest_version, + ) .filter(Leaderboard.id == leaderboard_id) .group_by(Leaderboard.id, Leaderboard.title, Leaderboard.description) - .one() - ) + ).one() return leaderboard @@ -1078,19 +1142,48 @@ def get_leaderboards( def get_position( - db_session: Session, leaderboard_id, address, window_size, limit: int, offset: int + db_session: Session, + leaderboard_id, + address, + window_size, + limit: int, + offset: int, + version_number: Optional[int] = None, ) -> List[Row[Tuple[str, int, int, int, Any]]]: """ - Return position by address with window size """ - query = db_session.query( - LeaderboardScores.address, - LeaderboardScores.score, - LeaderboardScores.points_data.label("points_data"), - func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), - func.row_number().over(order_by=LeaderboardScores.score.desc()).label("number"), - ).filter(LeaderboardScores.leaderboard_id == leaderboard_id) + + latest_version = leaderboard_version_filter( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=version_number, + ) + + query = ( + db_session.query( + LeaderboardScores.address, + LeaderboardScores.score, + LeaderboardScores.points_data.label("points_data"), + func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), + func.row_number() + .over(order_by=LeaderboardScores.score.desc()) + .label("number"), + ) + .join( + LeaderboardVersion, + and_( + LeaderboardVersion.leaderboard_id == LeaderboardScores.leaderboard_id, + LeaderboardVersion.version_number + == LeaderboardScores.leaderboard_version_number, + ), + ) + .filter( + LeaderboardVersion.published == True, + LeaderboardVersion.version_number == latest_version, + ) + .filter(LeaderboardScores.leaderboard_id == leaderboard_id) + ) ranked_leaderboard = query.cte(name="ranked_leaderboard") @@ -1130,11 +1223,25 @@ def get_position( def get_leaderboard_positions( - db_session: Session, leaderboard_id, limit: int, offset: int + db_session: Session, + leaderboard_id, + limit: int, + offset: int, + version_number: Optional[int] = None, ) -> List[Row[Tuple[uuid.UUID, str, int, str, int]]]: """ Get the leaderboard positions """ + + # get public leaderboard scores with max version + + latest_version = leaderboard_version_filter( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=version_number, + ) + + # Main query query = ( db_session.query( LeaderboardScores.id, @@ -1143,8 +1250,17 @@ def get_leaderboard_positions( LeaderboardScores.points_data, func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), ) + .join( + LeaderboardVersion, + and_( + LeaderboardVersion.leaderboard_id == LeaderboardScores.leaderboard_id, + LeaderboardVersion.version_number + == LeaderboardScores.leaderboard_version_number, + ), + ) .filter(LeaderboardScores.leaderboard_id == leaderboard_id) - .order_by(text("rank asc, id asc")) + .filter(LeaderboardVersion.published == True) + .filter(LeaderboardVersion.version_number == latest_version) ) if limit: @@ -1157,18 +1273,39 @@ def get_leaderboard_positions( def get_qurtiles( - db_session: Session, leaderboard_id + db_session: Session, leaderboard_id, version_number: Optional[int] = None ) -> Tuple[Row[Tuple[str, float, int]], ...]: """ Get the leaderboard qurtiles https://docs.sqlalchemy.org/en/14/core/functions.html#sqlalchemy.sql.functions.percentile_disc """ - query = db_session.query( - LeaderboardScores.address, - LeaderboardScores.score, - func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), - ).filter(LeaderboardScores.leaderboard_id == leaderboard_id) + latest_version = leaderboard_version_filter( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=version_number, + ) + + query = ( + db_session.query( + LeaderboardScores.address, + LeaderboardScores.score, + func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), + ) + .join( + LeaderboardVersion, + and_( + LeaderboardVersion.leaderboard_id == LeaderboardScores.leaderboard_id, + LeaderboardVersion.version_number + == LeaderboardScores.leaderboard_version_number, + ), + ) + .filter( + LeaderboardVersion.published == True, + LeaderboardVersion.version_number == latest_version, + ) + .filter(LeaderboardScores.leaderboard_id == leaderboard_id) + ) ranked_leaderboard = query.cte(name="ranked_leaderboard") @@ -1192,17 +1329,41 @@ def get_qurtiles( return q1, q2, q3 -def get_ranks(db_session: Session, leaderboard_id) -> List[Row[Tuple[int, int, int]]]: +def get_ranks( + db_session: Session, leaderboard_id, version_number: Optional[int] = None +) -> List[Row[Tuple[int, int, int]]]: """ Get the leaderboard rank buckets(rank, size, score) """ - query = db_session.query( - LeaderboardScores.id, - LeaderboardScores.address, - LeaderboardScores.score, - LeaderboardScores.points_data, - func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), - ).filter(LeaderboardScores.leaderboard_id == leaderboard_id) + + latest_version = leaderboard_version_filter( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=version_number, + ) + + query = ( + db_session.query( + LeaderboardScores.id, + LeaderboardScores.address, + LeaderboardScores.score, + LeaderboardScores.points_data, + func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), + ) + .join( + LeaderboardVersion, + and_( + LeaderboardVersion.leaderboard_id == LeaderboardScores.leaderboard_id, + LeaderboardVersion.version_number + == LeaderboardScores.leaderboard_version_number, + ), + ) + .filter( + LeaderboardVersion.published == True, + LeaderboardVersion.version_number == latest_version, + ) + .filter(LeaderboardScores.leaderboard_id == leaderboard_id) + ) ranked_leaderboard = query.cte(name="ranked_leaderboard") @@ -1220,10 +1381,18 @@ def get_rank( rank: int, limit: Optional[int] = None, offset: Optional[int] = None, + version_number: Optional[int] = None, ) -> List[Row[Tuple[uuid.UUID, str, int, str, int]]]: """ Get bucket in leaderboard by rank """ + + latest_version = leaderboard_version_filter( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=version_number, + ) + query = ( db_session.query( LeaderboardScores.id, @@ -1232,6 +1401,18 @@ def get_rank( LeaderboardScores.points_data, func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), ) + .join( + LeaderboardVersion, + and_( + LeaderboardVersion.leaderboard_id == LeaderboardScores.leaderboard_id, + LeaderboardVersion.version_number + == LeaderboardScores.leaderboard_version_number, + ), + ) + .filter( + LeaderboardVersion.published == True, + LeaderboardVersion.version_number == latest_version, + ) .filter(LeaderboardScores.leaderboard_id == leaderboard_id) .order_by(text("rank asc, id asc")) ) @@ -1377,7 +1558,7 @@ def add_scores( db_session: Session, leaderboard_id: uuid.UUID, scores: List[Score], - overwrite: bool = False, + version_number: int, normalize_addresses: bool = True, ): """ @@ -1397,16 +1578,6 @@ def add_scores( raise DuplicateLeaderboardAddressError("Dublicated addresses", duplicates) - if overwrite: - db_session.query(LeaderboardScores).filter( - LeaderboardScores.leaderboard_id == leaderboard_id - ).delete() - try: - db_session.commit() - except: - db_session.rollback() - raise LeaderboardDeleteScoresError("Error deleting leaderboard scores") - for score in scores: leaderboard_scores.append( { @@ -1414,13 +1585,18 @@ def add_scores( "address": normalizer_fn(score.address), "score": score.score, "points_data": score.points_data, + "leaderboard_version_number": version_number, } ) insert_statement = insert(LeaderboardScores).values(leaderboard_scores) result_stmt = insert_statement.on_conflict_do_update( - index_elements=[LeaderboardScores.address, LeaderboardScores.leaderboard_id], + index_elements=[ + LeaderboardScores.address, + LeaderboardScores.leaderboard_id, + LeaderboardScores.leaderboard_version_number, + ], set_=dict( score=insert_statement.excluded.score, points_data=insert_statement.excluded.points_data, @@ -1436,7 +1612,7 @@ def add_scores( return leaderboard_scores -# leadrboard access actions +# leaderboard access actions def create_leaderboard_resource( @@ -1675,3 +1851,159 @@ def check_leaderboard_resource_permissions( return True return False + + +def get_leaderboard_version( + db_session: Session, leaderboard_id: uuid.UUID, version_number: int +) -> LeaderboardVersion: + """ + Get the leaderboard version by id + """ + return ( + db_session.query(LeaderboardVersion) + .filter(LeaderboardVersion.leaderboard_id == leaderboard_id) + .filter(LeaderboardVersion.version_number == version_number) + .one() + ) + + +def create_leaderboard_version( + db_session: Session, + leaderboard_id: uuid.UUID, + version_number: Optional[int] = None, + publish: bool = False, +) -> LeaderboardVersion: + """ + Create a leaderboard version + """ + + if version_number is None: + latest_version_result = ( + db_session.query(func.max(LeaderboardVersion.version_number)) + .filter(LeaderboardVersion.leaderboard_id == leaderboard_id) + .one() + ) + + latest_version = latest_version_result[0] + + if latest_version is None: + version_number = 0 + else: + version_number = latest_version + 1 + + leaderboard_version = LeaderboardVersion( + leaderboard_id=leaderboard_id, + version_number=version_number, + published=publish, + ) + + db_session.add(leaderboard_version) + db_session.commit() + + return leaderboard_version + + +def change_publish_leaderboard_version_status( + db_session: Session, leaderboard_id: uuid.UUID, version_number: int, published: bool +) -> LeaderboardVersion: + """ + Publish a leaderboard version + """ + leaderboard_version = ( + db_session.query(LeaderboardVersion) + .filter(LeaderboardVersion.leaderboard_id == leaderboard_id) + .filter(LeaderboardVersion.version_number == version_number) + .one() + ) + + leaderboard_version.published = published + + db_session.commit() + + return leaderboard_version + + +def get_leaderboard_versions( + db_session: Session, leaderboard_id: uuid.UUID +) -> List[LeaderboardVersion]: + """ + Get all leaderboard versions + """ + return ( + db_session.query(LeaderboardVersion) + .filter(LeaderboardVersion.leaderboard_id == leaderboard_id) + .all() + ) + + +def delete_leaderboard_version( + db_session: Session, leaderboard_id: uuid.UUID, version_number: int +) -> LeaderboardVersion: + """ + Delete a leaderboard version + """ + leaderboard_version = ( + db_session.query(LeaderboardVersion) + .filter(LeaderboardVersion.leaderboard_id == leaderboard_id) + .filter(LeaderboardVersion.version_number == version_number) + .one() + ) + + db_session.delete(leaderboard_version) + db_session.commit() + + return leaderboard_version + + +def get_leaderboard_version_scores( + db_session: Session, + leaderboard_id: uuid.UUID, + version_number: int, + limit: int, + offset: int, +) -> List[LeaderboardScores]: + """ + Get the leaderboard scores by version number + """ + + query = ( + db_session.query( + LeaderboardScores.id, + LeaderboardScores.address.label("address"), + LeaderboardScores.score.label("score"), + LeaderboardScores.points_data.label("points_data"), + func.rank().over(order_by=LeaderboardScores.score.desc()).label("rank"), + ) + .filter(LeaderboardScores.leaderboard_id == leaderboard_id) + .filter(LeaderboardScores.leaderboard_version_number == version_number) + ) + + if limit: + query = query.limit(limit) + + if offset: + query = query.offset(offset) + + return query + + +def delete_previous_versions( + db_session: Session, + leaderboard_id: uuid.UUID, + threshold_version_number: int, +) -> int: + """ + Delete old leaderboard versions + """ + + versions_to_delete = ( + db_session.query(LeaderboardVersion) + .filter(LeaderboardVersion.leaderboard_id == leaderboard_id) + .filter(LeaderboardVersion.version_number < threshold_version_number) + ) + + num_deleted = versions_to_delete.delete(synchronize_session=False) + + db_session.commit() + + return num_deleted diff --git a/engineapi/engineapi/data.py b/engineapi/engineapi/data.py index 8a33d619..395d913d 100644 --- a/engineapi/engineapi/data.py +++ b/engineapi/engineapi/data.py @@ -442,3 +442,15 @@ class LeaderboardConfigUpdate(BaseModel): query_name: Optional[str] = None params: Dict[str, int] normalize_addresses: Optional[bool] = None + + +class LeaderboardVersion(BaseModel): + leaderboard_id: UUID + version: int + published: bool + created_at: datetime + updated_at: datetime + + +class LeaderboardVersionRequest(BaseModel): + publish: bool diff --git a/engineapi/engineapi/models.py b/engineapi/engineapi/models.py index 36cc975d..c516cde2 100644 --- a/engineapi/engineapi/models.py +++ b/engineapi/engineapi/models.py @@ -13,6 +13,7 @@ from sqlalchemy import ( MetaData, String, UniqueConstraint, + ForeignKeyConstraint, ) from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.ext.compiler import compiles @@ -357,9 +358,45 @@ class Leaderboard(Base): # type: ignore ) +class LeaderboardVersion(Base): # type: ignore + __tablename__ = "leaderboard_versions" + __table_args__ = (UniqueConstraint("leaderboard_id", "version_number"),) + + leaderboard_id = Column( + UUID(as_uuid=True), + ForeignKey("leaderboards.id", ondelete="CASCADE"), + primary_key=True, + nullable=False, + ) + version_number = Column(DECIMAL, primary_key=True, nullable=False) + published = Column(Boolean, default=False, nullable=False) + created_at = Column( + DateTime(timezone=True), + server_default=utcnow(), + nullable=False, + index=True, + ) + updated_at = Column( + DateTime(timezone=True), + server_default=utcnow(), + onupdate=utcnow(), + nullable=False, + ) + + class LeaderboardScores(Base): # type: ignore __tablename__ = "leaderboard_scores" - __table_args__ = (UniqueConstraint("leaderboard_id", "address"),) + __table_args__ = ( + UniqueConstraint("leaderboard_id", "address", "leaderboard_version_number"), + ForeignKeyConstraint( + ["leaderboard_id", "leaderboard_version_number"], + [ + "leaderboard_versions.leaderboard_id", + "leaderboard_versions.version_number", + ], + ondelete="CASCADE", + ), + ) id = Column( UUID(as_uuid=True), @@ -370,7 +407,10 @@ class LeaderboardScores(Base): # type: ignore ) leaderboard_id = Column( UUID(as_uuid=True), - ForeignKey("leaderboards.id", ondelete="CASCADE"), + nullable=False, + ) + leaderboard_version_number = Column( + DECIMAL, nullable=False, ) address = Column(VARCHAR(256), nullable=False, index=True) diff --git a/engineapi/engineapi/routes/leaderboard.py b/engineapi/engineapi/routes/leaderboard.py index 15050f31..20c6e0dd 100644 --- a/engineapi/engineapi/routes/leaderboard.py +++ b/engineapi/engineapi/routes/leaderboard.py @@ -48,7 +48,7 @@ AuthHeader = Header( ) -leaderboad_whitelist = { +leaderboard_whitelist = { f"/leaderboard/{DOCS_TARGET_PATH}": "GET", "/leaderboard/openapi.json": "GET", "/leaderboard/info": "GET", @@ -76,7 +76,7 @@ app = FastAPI( ) -app.add_middleware(ExtractBearerTokenMiddleware, whitelist=leaderboad_whitelist) +app.add_middleware(ExtractBearerTokenMiddleware, whitelist=leaderboard_whitelist) app.add_middleware( CORSMiddleware, @@ -87,13 +87,19 @@ app.add_middleware( ) -@app.get("", response_model=List[data.LeaderboardPosition], tags=["Public Endpoints"]) +@app.get( + "", + response_model=List[data.LeaderboardPosition], + tags=["Public Endpoints"], + include_in_schema=False, +) @app.get("/", response_model=List[data.LeaderboardPosition], tags=["Public Endpoints"]) async def leaderboard( leaderboard_id: UUID = Query(..., description="Leaderboard ID"), limit: int = Query(10), offset: int = Query(0), db_session: Session = Depends(db.yield_db_session), + version: Optional[str] = Query(None, description="Version of the leaderboard."), ) -> List[data.LeaderboardPosition]: """ Returns the leaderboard positions. @@ -112,7 +118,7 @@ async def leaderboard( raise EngineHTTPException(status_code=500, detail="Internal server error") leaderboard_positions = actions.get_leaderboard_positions( - db_session, leaderboard_id, limit, offset + db_session, leaderboard_id, limit, offset, version ) result = [ data.LeaderboardPosition( @@ -128,7 +134,10 @@ async def leaderboard( @app.post( - "", response_model=data.LeaderboardCreatedResponse, tags=["Authorized Endpoints"] + "", + response_model=data.LeaderboardCreatedResponse, + tags=["Authorized Endpoints"], + include_in_schema=False, ) @app.post( "/", response_model=data.LeaderboardCreatedResponse, tags=["Authorized Endpoints"] @@ -346,6 +355,7 @@ async def get_leaderboards( ) async def count_addresses( leaderboard_id: UUID = Query(..., description="Leaderboard ID"), + version: Optional[int] = Query(None, description="Version of the leaderboard."), db_session: Session = Depends(db.yield_db_session), ) -> data.CountAddressesResponse: """ @@ -364,7 +374,7 @@ async def count_addresses( logger.error(f"Error while getting leaderboard: {e}") raise EngineHTTPException(status_code=500, detail="Internal server error") - count = actions.get_leaderboard_total_count(db_session, leaderboard_id) + count = actions.get_leaderboard_total_count(db_session, leaderboard_id, version) return data.CountAddressesResponse(count=count) @@ -375,12 +385,13 @@ async def count_addresses( async def leadeboard_info( leaderboard_id: UUID = Query(..., description="Leaderboard ID"), db_session: Session = Depends(db.yield_db_session), + version: Optional[int] = Query(None, description="Version of the leaderboard."), ) -> data.LeaderboardInfoResponse: """ Returns leaderboard info. """ try: - leaderboard = actions.get_leaderboard_info(db_session, leaderboard_id) + leaderboard = actions.get_leaderboard_info(db_session, leaderboard_id, version) except NoResultFound as e: raise EngineHTTPException( status_code=404, @@ -434,6 +445,7 @@ async def get_scores_changes( async def quartiles( leaderboard_id: UUID = Query(..., description="Leaderboard ID"), db_session: Session = Depends(db.yield_db_session), + version: Optional[int] = Query(None, description="Version of the leaderboard."), ) -> data.QuartilesResponse: """ Returns the quartiles of the leaderboard. @@ -451,7 +463,7 @@ async def quartiles( raise EngineHTTPException(status_code=500, detail="Internal server error") try: - q1, q2, q3 = actions.get_qurtiles(db_session, leaderboard_id) + q1, q2, q3 = actions.get_qurtiles(db_session, leaderboard_id, version) except actions.LeaderboardIsEmpty: raise EngineHTTPException(status_code=204, detail="Leaderboard is empty.") @@ -480,6 +492,7 @@ async def position( normalize_addresses: bool = Query( True, description="Normalize addresses to checksum." ), + version: Optional[int] = Query(None, description="Version of the leaderboard."), db_session: Session = Depends(db.yield_db_session), ) -> List[data.LeaderboardPosition]: """ @@ -503,7 +516,13 @@ async def position( address = Web3.toChecksumAddress(address) positions = actions.get_position( - db_session, leaderboard_id, address, window_size, limit, offset + db_session, + leaderboard_id, + address, + window_size, + limit, + offset, + version, ) results = [ @@ -527,6 +546,7 @@ async def rank( rank: int = Query(1, description="Rank to get."), limit: Optional[int] = Query(None), offset: Optional[int] = Query(None), + version: Optional[int] = Query(None, description="Version of the leaderboard."), db_session: Session = Depends(db.yield_db_session), ) -> List[data.LeaderboardPosition]: """ @@ -546,7 +566,12 @@ async def rank( raise EngineHTTPException(status_code=500, detail="Internal server error") leaderboard_rank = actions.get_rank( - db_session, leaderboard_id, rank, limit=limit, offset=offset + db_session, + leaderboard_id, + rank, + limit=limit, + offset=offset, + version_number=version, ) results = [ data.LeaderboardPosition( @@ -563,6 +588,7 @@ async def rank( @app.get("/ranks", response_model=List[data.RanksResponse], tags=["Public Endpoints"]) async def ranks( leaderboard_id: UUID = Query(..., description="Leaderboard ID"), + version: Optional[int] = Query(None, description="Version of the leaderboard."), db_session: Session = Depends(db.yield_db_session), ) -> List[data.RanksResponse]: """ @@ -581,7 +607,7 @@ async def ranks( logger.error(f"Error while getting leaderboard: {e}") raise EngineHTTPException(status_code=500, detail="Internal server error") - ranks = actions.get_ranks(db_session, leaderboard_id) + ranks = actions.get_ranks(db_session, leaderboard_id, version) results = [ data.RanksResponse( score=rank.score, @@ -604,10 +630,6 @@ async def leaderboard_push_scores( scores: List[data.Score] = Body( ..., description="Scores to put to the leaderboard." ), - overwrite: bool = Query( - False, - description="If enabled, this will delete all current scores and replace them with the new scores provided.", - ), normalize_addresses: bool = Query( True, description="Normalize addresses to checksum." ), @@ -635,13 +657,22 @@ async def leaderboard_push_scores( status_code=403, detail="You don't have access to this leaderboard." ) + try: + new_version = actions.create_leaderboard_version( + db_session=db_session, + leaderboard_id=leaderboard_id, + ) + except Exception as e: + logger.error(f"Error while creating leaderboard version: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + try: leaderboard_points = actions.add_scores( db_session=db_session, leaderboard_id=leaderboard_id, scores=scores, - overwrite=overwrite, normalize_addresses=normalize_addresses, + version_number=new_version.version_number, ) except actions.DuplicateLeaderboardAddressError as e: raise EngineHTTPException( @@ -658,6 +689,27 @@ async def leaderboard_push_scores( logger.error(f"Score update failed with error: {e}") raise EngineHTTPException(status_code=500, detail="Score update failed.") + try: + actions.change_publish_leaderboard_version_status( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=new_version.version_number, + published=True, + ) + except Exception as e: + logger.error(f"Error while updating leaderboard version: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + try: + actions.delete_previous_versions( + db_session=db_session, + leaderboard_id=leaderboard_id, + threshold_version_number=new_version.version_number, + ) + except Exception as e: + logger.error(f"Error while deleting leaderboard versions: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + result = [ data.LeaderboardScore( leaderboard_id=score["leaderboard_id"], @@ -881,3 +933,422 @@ async def leaderboard_config_deactivate( raise EngineHTTPException(status_code=500, detail="Internal server error") return True + + +@app.get( + "/{leaderboard_id}/versions", + response_model=List[data.LeaderboardVersion], + tags=["Authorized Endpoints"], +) +async def leaderboard_versions_list( + request: Request, + leaderboard_id: UUID = Path(..., description="Leaderboard ID"), + db_session: Session = Depends(db.yield_db_session), + Authorization: str = AuthHeader, +) -> List[data.LeaderboardVersion]: + """ + Get leaderboard versions list. + """ + 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 version not found.", + ) + + if not access: + raise EngineHTTPException( + status_code=403, detail="You don't have access to this leaderboard version." + ) + + try: + leaderboard_versions = actions.get_leaderboard_versions( + db_session=db_session, + leaderboard_id=leaderboard_id, + ) + except Exception as e: + logger.error(f"Error while getting leaderboard versions list: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + result = [ + data.LeaderboardVersion( + leaderboard_id=version.leaderboard_id, + version=version.version_number, + published=version.published, + created_at=version.created_at, + updated_at=version.updated_at, + ) + for version in leaderboard_versions + ] + + return result + + +@app.get( + "/{leaderboard_id}/versions/{version}", + response_model=data.LeaderboardVersion, + tags=["Authorized Endpoints"], +) +async def leaderboard_version_handler( + request: Request, + leaderboard_id: UUID = Path(..., description="Leaderboard ID"), + version: int = Path(..., description="Version of the leaderboard."), + db_session: Session = Depends(db.yield_db_session), + Authorization: str = AuthHeader, +) -> data.LeaderboardVersion: + """ + Get leaderboard version. + """ + 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_version = actions.get_leaderboard_version( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=version, + ) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard version not found.", + ) + except Exception as e: + logger.error(f"Error while getting leaderboard version: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + return data.LeaderboardVersion( + leaderboard_id=leaderboard_version.leaderboard_id, + version=leaderboard_version.version_number, + published=leaderboard_version.published, + created_at=leaderboard_version.created_at, + updated_at=leaderboard_version.updated_at, + ) + + +@app.post( + "/{leaderboard_id}/versions", + response_model=data.LeaderboardVersion, + tags=["Authorized Endpoints"], +) +async def create_leaderboard_version( + request: Request, + leaderboard_id: UUID = Path(..., description="Leaderboard ID"), + db_session: Session = Depends(db.yield_db_session), + request_body: data.LeaderboardVersionRequest = Body( + ..., + description="JSON object specifying whether to publish or unpublish version.", + ), + Authorization: str = AuthHeader, +) -> data.LeaderboardVersion: + """ + Create leaderboard version. + """ + 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: + new_version = actions.create_leaderboard_version( + db_session=db_session, + leaderboard_id=leaderboard_id, + publish=request_body.publish, + ) + except Exception as e: + logger.error(f"Error while creating leaderboard version: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + return data.LeaderboardVersion( + leaderboard_id=new_version.leaderboard_id, + version=new_version.version_number, + published=new_version.published, + created_at=new_version.created_at, + updated_at=new_version.updated_at, + ) + + +@app.put( + "/{leaderboard_id}/versions/{version}", + response_model=data.LeaderboardVersion, + tags=["Authorized Endpoints"], +) +async def update_leaderboard_version_handler( + request: Request, + leaderboard_id: UUID = Path(..., description="Leaderboard ID"), + version: int = Path(..., description="Version of the leaderboard."), + request_body: data.LeaderboardVersionRequest = Body( + ..., + description="JSON object specifying whether to publish or unpublish version.", + ), + db_session: Session = Depends(db.yield_db_session), + Authorization: str = AuthHeader, +) -> data.LeaderboardVersion: + """ + Update leaderboard version. + """ + 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 version not found.", + ) + + if not access: + raise EngineHTTPException( + status_code=403, detail="You don't have access to this leaderboard version." + ) + + try: + leaderboard_version = actions.change_publish_leaderboard_version_status( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=version, + published=request_body.publish, + ) + except Exception as e: + logger.error(f"Error while updating leaderboard version: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + return data.LeaderboardVersion( + leaderboard_id=leaderboard_version.leaderboard_id, + version=leaderboard_version.version_number, + published=leaderboard_version.published, + created_at=leaderboard_version.created_at, + updated_at=leaderboard_version.updated_at, + ) + + +@app.delete( + "/{leaderboard_id}/versions/{version}", + response_model=data.LeaderboardVersion, + tags=["Authorized Endpoints"], +) +async def delete_leaderboard_version_handler( + request: Request, + leaderboard_id: UUID = Path(..., description="Leaderboard ID"), + version: int = Path(..., description="Version of the leaderboard."), + db_session: Session = Depends(db.yield_db_session), + Authorization: str = AuthHeader, +) -> data.LeaderboardVersion: + """ + Delete leaderboard version. + """ + 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 version not found.", + ) + + if not access: + raise EngineHTTPException( + status_code=403, detail="You don't have access to this leaderboard version." + ) + + try: + leaderboard_version = actions.delete_leaderboard_version( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=version, + ) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard version not found.", + ) + except Exception as e: + logger.error(f"Error while deleting leaderboard version: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + return data.LeaderboardVersion( + leaderboard_id=leaderboard_version.leaderboard_id, + version=leaderboard_version.version_number, + published=leaderboard_version.published, + created_at=leaderboard_version.created_at, + updated_at=leaderboard_version.updated_at, + ) + + +@app.get( + "/{leaderboard_id}/versions/{version}/scores", + response_model=List[data.LeaderboardPosition], + tags=["Authorized Endpoints"], +) +async def leaderboard_version_scores_handler( + request: Request, + leaderboard_id: UUID = Path(..., description="Leaderboard ID"), + version: int = Path(..., description="Version of the leaderboard."), + limit: int = Query(10), + offset: int = Query(0), + db_session: Session = Depends(db.yield_db_session), + Authorization: str = AuthHeader, +) -> List[data.LeaderboardPosition]: + """ + Get leaderboard version scores. + """ + 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 version not found.", + ) + + if not access: + raise EngineHTTPException( + status_code=403, detail="You don't have access to this leaderboard version." + ) + + try: + leaderboard_version_scores = actions.get_leaderboard_version_scores( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=version, + limit=limit, + offset=offset, + ) + except Exception as e: + logger.error(f"Error while getting leaderboard version scores: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + result = [ + data.LeaderboardPosition( + address=score.address, + score=score.score, + rank=score.rank, + points_data=score.points_data, + ) + for score in leaderboard_version_scores + ] + + return result + + +@app.put( + "/{leaderboard_id}/versions/{version}/scores", + response_model=List[data.LeaderboardScore], + tags=["Authorized Endpoints"], +) +async def leaderboard_version_push_scores_handler( + request: Request, + leaderboard_id: UUID = Path(..., description="Leaderboard ID"), + version: int = Path(..., description="Version of the leaderboard."), + scores: List[data.Score] = Body( + ..., description="Scores to put to the leaderboard version." + ), + normalize_addresses: bool = Query( + True, description="Normalize addresses to checksum." + ), + db_session: Session = Depends(db.yield_db_session), + Authorization: str = AuthHeader, +) -> List[data.LeaderboardScore]: + """ + Put the leaderboard version to the database. + """ + 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 version not found.", + ) + + if not access: + raise EngineHTTPException( + status_code=403, detail="You don't have access to this leaderboard version." + ) + + try: + leaderboard_version = actions.get_leaderboard_version( + db_session=db_session, + leaderboard_id=leaderboard_id, + version_number=version, + ) + except NoResultFound as e: + raise EngineHTTPException( + status_code=404, + detail="Leaderboard version not found.", + ) + except Exception as e: + logger.error(f"Error while getting leaderboard version: {e}") + raise EngineHTTPException(status_code=500, detail="Internal server error") + + try: + leaderboard_points = actions.add_scores( + db_session=db_session, + leaderboard_id=leaderboard_id, + scores=scores, + normalize_addresses=normalize_addresses, + version_number=leaderboard_version.version_number, + ) + except actions.DuplicateLeaderboardAddressError as e: + raise EngineHTTPException( + status_code=409, + detail=f"Duplicates in push to database is disallowed.\n List of duplicates:{e.duplicates}.\n Please handle duplicates manualy.", + ) + except Exception as e: + logger.error(f"Score update failed with error: {e}") + raise EngineHTTPException(status_code=500, detail="Score update failed.") + + result = [ + data.LeaderboardScore( + leaderboard_id=score["leaderboard_id"], + address=score["address"], + score=score["score"], + points_data=score["points_data"], + ) + for score in leaderboard_points + ] + + return result