From 7a5ae6f8e66048ac73c89125f3b985b545e6be30 Mon Sep 17 00:00:00 2001 From: tsunyoku Date: Sun, 7 Jul 2024 17:47:47 +0100 Subject: [PATCH] Add public user stats API --- app/api/public/__init__.py | 2 + app/api/public/user_stats.py | 46 +++++++++++++++++++++++ app/common_types.py | 36 +++++++++++++++++- app/models/user_stats.py | 20 ++++++++++ app/repositories/user_stats.py | 67 ++++++++++++++++++++++++++++++++++ app/usecases/user_stats.py | 33 +++++++++++++++++ 6 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 app/api/public/user_stats.py create mode 100644 app/models/user_stats.py create mode 100644 app/repositories/user_stats.py create mode 100644 app/usecases/user_stats.py diff --git a/app/api/public/__init__.py b/app/api/public/__init__.py index 9707bf7..b5058be 100644 --- a/app/api/public/__init__.py +++ b/app/api/public/__init__.py @@ -2,8 +2,10 @@ from . import authentication from . import users +from . import user_stats public_router = APIRouter() public_router.include_router(authentication.router) public_router.include_router(users.router) +public_router.include_router(user_stats.router) diff --git a/app/api/public/user_stats.py b/app/api/public/user_stats.py new file mode 100644 index 0000000..13008d9 --- /dev/null +++ b/app/api/public/user_stats.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter +from fastapi import Response +from fastapi import Query + +from app.api.responses import JSONResponse +from app.errors import Error +from app.errors import ErrorCode +from app.common_types import GameMode +from app.common_types import RelaxMode +from app.common_types import Mode +from app.usecases import user_stats + +router = APIRouter(tags=["(Public) User Stats API"]) + +def map_error_code_to_http_status_code(error_code: ErrorCode) -> int: + return _error_code_to_http_status_code_map[error_code] + + +_error_code_to_http_status_code_map: dict[ErrorCode, int] = { + ErrorCode.INCORRECT_CREDENTIALS: 401, + ErrorCode.INSUFFICIENT_PRIVILEGES: 401, + ErrorCode.PENDING_VERIFICATION: 401, + ErrorCode.NOT_FOUND: 404, + ErrorCode.INTERNAL_SERVER_ERROR: 500, +} + +@router.get("/public/api/v1/users/{user_id}/stats") +async def get_user_stats( + user_id: int, + game_mode: GameMode = Query(...), + relax_mode: RelaxMode = Query(...), +) -> Response: + mode = Mode.from_game_mode_and_relax_mode(game_mode, relax_mode) + + response = await user_stats.fetch_one_by_user_id_and_mode(user_id, mode) + if isinstance(response, Error): + return JSONResponse( + content=response.model_dump(), + status_code=map_error_code_to_http_status_code(response.error_code), + ) + + return JSONResponse( + content=response.model_dump(), + status_code=200, + ) + diff --git a/app/common_types.py b/app/common_types.py index d1ff044..85a26c0 100644 --- a/app/common_types.py +++ b/app/common_types.py @@ -1,6 +1,5 @@ -from enum import IntEnum from enum import IntFlag - +from enum import IntEnum class UserPrivileges(IntFlag): USER_PUBLIC = 1 << 0 @@ -46,3 +45,36 @@ class GameMode(IntEnum): TAIKO = 1 CATCH = 2 MANIA = 3 + + +class RelaxMode(IntEnum): + VANILLA = 0 + RELAX = 1 + AUTOPILOT = 2 + + +class Mode(IntEnum): + OSU = 0 + TAIKO = 1 + CATCH = 2 + MANIA = 3 + + RELAX_OSU = 4 + RELAX_TAIKO = 5 + RELAX_CATCH = 6 + + AUTOPILOT_OSU = 8 + + @staticmethod + def from_game_mode_and_relax_mode( + game_mode: GameMode, + relax_mode: RelaxMode, + ) -> "Mode": + if relax_mode is RelaxMode.VANILLA: + return Mode(game_mode.value) + elif relax_mode is RelaxMode.RELAX and game_mode is not GameMode.MANIA: + return Mode(game_mode.value + 4) + elif relax_mode is RelaxMode.AUTOPILOT and game_mode is GameMode.OSU: + return Mode.AUTOPILOT_OSU + else: + raise ValueError("Unknown game_mode and relax_mode combo") diff --git a/app/models/user_stats.py b/app/models/user_stats.py new file mode 100644 index 0000000..41741f4 --- /dev/null +++ b/app/models/user_stats.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel + +class UserStats(BaseModel): + ranked_score: int + total_score: int + play_count: int + replays_watched: int + total_hits: int + accuracy: float + pp: int + play_time: int + ssh_count: int + ss_count: int + sh_count: int + s_count: int + a_count: int + b_count: int + c_count: int + d_count: int + max_combo: int diff --git a/app/repositories/user_stats.py b/app/repositories/user_stats.py new file mode 100644 index 0000000..e7338da --- /dev/null +++ b/app/repositories/user_stats.py @@ -0,0 +1,67 @@ +from pydantic import BaseModel + +from app.common_types import Mode + +import app.state + + +class UserStats(BaseModel): + ranked_score: int + total_score: int + playcount: int + replays_watched: int + total_hits: int + avg_accuracy: float + pp: int + playtime: int + xh_count: int + x_count: int + sh_count: int + s_count: int + a_count: int + b_count: int + c_count: int + d_count: int + max_combo: int + + +READ_PARAMS = """\ + ranked_score, total_score, playcount, replays_watched, total_hits, avg_accuracy, pp, playtime, + xh_count, x_count, sh_count, s_count, a_count, b_count, c_count, d_count, max_combo +""" + +async def fetch_one_by_user_id_and_mode( + user_id: int, + mode: Mode, +) -> UserStats | None: + query = f""" + SELECT {READ_PARAMS} + FROM user_stats + WHERE user_id = :user_id + AND mode = :mode + """ + params = {"user_id": user_id, "mode": mode.value} + + user_stats = await app.state.database.fetch_one(query, params) + if user_stats is None: + return None + + return UserStats( + ranked_score=user_stats["ranked_score"], + total_score=user_stats["total_score"], + playcount=user_stats["playcount"], + replays_watched=user_stats["replays_watched"], + total_hits=user_stats["total_hits"], + avg_accuracy=user_stats["avg_accuracy"], + pp=user_stats["pp"], + playtime=user_stats["playtime"], + xh_count=user_stats["xh_count"], + x_count=user_stats["x_count"], + sh_count=user_stats["sh_count"], + s_count=user_stats["s_count"], + a_count=user_stats["a_count"], + b_count=user_stats["b_count"], + c_count=user_stats["c_count"], + d_count=user_stats["d_count"], + max_combo=user_stats["max_combo"], + ) diff --git a/app/usecases/user_stats.py b/app/usecases/user_stats.py new file mode 100644 index 0000000..9d81573 --- /dev/null +++ b/app/usecases/user_stats.py @@ -0,0 +1,33 @@ +from app.common_types import Mode +from app.models.user_stats import UserStats +from app.errors import Error +from app.errors import ErrorCode +from app.repositories import user_stats + +async def fetch_one_by_user_id_and_mode(user_id: int, mode: Mode) -> UserStats | Error: + stats = await user_stats.fetch_one_by_user_id_and_mode(user_id, mode) + if stats is None: + return Error( + error_code=ErrorCode.NOT_FOUND, + user_feedback="User statistics not found.", + ) + + return UserStats( + ranked_score=stats.ranked_score, + total_score=stats.total_score, + play_count=stats.playcount, + replays_watched=stats.replays_watched, + total_hits=stats.total_hits, + accuracy=stats.avg_accuracy, + pp=stats.pp, + play_time=stats.playtime, + ssh_count=stats.xh_count, + ss_count=stats.x_count, + sh_count=stats.sh_count, + s_count=stats.s_count, + a_count=stats.a_count, + b_count=stats.b_count, + c_count=stats.c_count, + d_count=stats.d_count, + max_combo=stats.max_combo, + ) \ No newline at end of file