diff --git a/.github/workflows/update_i18n.yaml b/.github/workflows/update_i18n.yaml index 4c1dfc7..dc5663c 100644 --- a/.github/workflows/update_i18n.yaml +++ b/.github/workflows/update_i18n.yaml @@ -21,8 +21,8 @@ jobs: - name: Check if push is from GitHub Actions id: check_ci_step run: | - if [ "$GITHUB_ACTOR" = "github-actions" ]; then - echo "Prevent infinite loop by CI" + LAST_COMMITTER=$(git log -1 --pretty=format:'%an') + if [ "$LAST_COMMITTER" = "GitHub Actions" ]; then exit 0 fi diff --git a/i18n/EUen.json b/i18n/EUen.json index d9cc4ed..35b6f4b 100644 --- a/i18n/EUen.json +++ b/i18n/EUen.json @@ -91,7 +91,17 @@ "column_total_x_power_title": "Total X Power", "column_peak_xpower_title": "Peak X Power", "column_percent_games_played_title": "% Games Played", - "column_season_number_title": "Season" + "column_season_number_title": "Season", + "weapon_select_main": "Weapon Select", + "weapon_select_alt": "Weapon Select (Alt)", + "weapon_title": "Top Weapon Wielders", + "threshold_select": "Minimum Percent Usage", + "weapon_leaderboard.peak_x_power": "Peak X Power", + "weapon_leaderboard.final_x_power": "Season End X Power", + "select_weapon": "Select Weapon", + "null_selection": "No Alt Weapon", + "no_data": "No data available", + "errors.503": "Service Unavailable, please try again later." }, "navigation": { "top500": "Top 500", @@ -99,7 +109,8 @@ "footer.contact": "Contact", "footer.rights": "%DATE% splat.top. All rights reserved.", "navbar.faq": "FAQ", - "navbar.analytics": "Analytics" + "navbar.analytics": "Analytics", + "navbar.top_weapons": "Top Weapons" }, "player": { "data_lang_key": "USen", diff --git a/i18n/USen.json b/i18n/USen.json index 8b0eee2..696f914 100644 --- a/i18n/USen.json +++ b/i18n/USen.json @@ -86,12 +86,22 @@ "column_weapon_not_supported": "Weapon name not yet supported", "column_xpower_title": "X Power", "column_peak_xpower_title": "Peak X Power", - "column_percent_games_played_title": "% Games Played", + "column_percent_games_played_title": "Est. % Usage", "column_season_number_title": "Season", "select_columns": "Select columns", "modes": "Modes", "region": "Region", - "column_total_x_power_title": "Total X Power" + "column_total_x_power_title": "Total X Power", + "weapon_select_main": "Weapon Select", + "weapon_select_alt": "Weapon Select (Alt)", + "weapon_title": "Top Weapon Wielders", + "threshold_select": "Minimum Percent Usage", + "weapon_leaderboard.peak_x_power": "Peak X Power", + "weapon_leaderboard.final_x_power": "Season End X Power", + "select_weapon": "Select Weapon", + "null_selection": "No Alt Weapon", + "no_data": "No data available", + "errors.503": "Service Unavailable, please try again later." }, "navigation": { "top500": "Top 500", @@ -99,7 +109,8 @@ "footer.contact": "Contact", "footer.rights": "%DATE% splat.top. All rights reserved.", "navbar.faq": "FAQ", - "navbar.analytics": "Analytics" + "navbar.analytics": "Analytics", + "navbar.top_weapons": "Top Weapons" }, "player": { "data_lang_key": "USen", diff --git a/i18n/USes.json b/i18n/USes.json index 355706d..0ab84a9 100644 --- a/i18n/USes.json +++ b/i18n/USes.json @@ -91,7 +91,17 @@ "column_total_x_power_title": "Fuerza X Total", "column_peak_xpower_title": "Peak X Power", "column_percent_games_played_title": "% Games Played", - "column_season_number_title": "Season" + "column_season_number_title": "Season", + "weapon_select_main": "Weapon Select", + "weapon_select_alt": "Weapon Select (Alt)", + "weapon_title": "Top Weapon Wielders", + "threshold_select": "Minimum Percent Usage", + "weapon_leaderboard.peak_x_power": "Peak X Power", + "weapon_leaderboard.final_x_power": "Season End X Power", + "select_weapon": "Select Weapon", + "null_selection": "No Alt Weapon", + "no_data": "No data available", + "errors.503": "Service Unavailable, please try again later." }, "navigation": { "top500": "Top 500", @@ -99,7 +109,8 @@ "footer.contact": "Contacto", "footer.rights": "%DATE% splat.top. Todos los derechos reservados.", "navbar.faq": "FAQ", - "navbar.analytics": "Analítica" + "navbar.analytics": "Analítica", + "navbar.top_weapons": "Top Weapons" }, "player": { "data_lang_key": "USes", diff --git a/poetry.lock b/poetry.lock index 944c5fb..7d6dd98 100644 --- a/poetry.lock +++ b/poetry.lock @@ -323,6 +323,18 @@ files = [ {file = "Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724"}, ] +[[package]] +name = "cachetools" +version = "5.3.3" +description = "Extensible memoizing collections and decorators" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "cachetools-5.3.3-py3-none-any.whl", hash = "sha256:0abad1021d3f8325b2fc1d2e9c8b9c9d57b04c3932657a72465447332c24d945"}, + {file = "cachetools-5.3.3.tar.gz", hash = "sha256:ba29e2dfa0b8b556606f097407ed1aa62080ee108ab0dc5ec9d6a723a007d105"}, +] + [[package]] name = "celery" version = "5.3.6" @@ -2850,4 +2862,4 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "2bd66c4886a02f3af395432438abd20c9eb50f45dab7a1b7b2da269d03104c84" +content-hash = "a17ae3e07e7b6779884d451aa9083435c5b42ce5b7b55b22875a61774fe6d0fe" diff --git a/pyproject.toml b/pyproject.toml index ef895d5..6aaa80f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ orjson = "^3.10.1" slowapi = "^0.1.9" gunicorn = "^22.0.0" scipy = "^1.13.1" +cachetools = "^5.3.3" [tool.poetry.group.dev.dependencies] diff --git a/src/celery_app/app.py b/src/celery_app/app.py index 09915cc..553a72f 100644 --- a/src/celery_app/app.py +++ b/src/celery_app/app.py @@ -7,6 +7,10 @@ from celery_app.tasks.analytics.lorenz import compute_lorenz_and_gini from celery_app.tasks.analytics.skill_offset import compute_skill_offset from celery_app.tasks.front_page import pull_data +from celery_app.tasks.leaderboard import ( + fetch_season_results, + fetch_weapon_leaderboard, +) from celery_app.tasks.misc import pull_aliases, update_weapon_info from celery_app.tasks.player_detail import fetch_player_data from shared_lib.constants import REDIS_URI @@ -25,3 +29,5 @@ celery.task(name="tasks.pull_aliases")(pull_aliases) celery.task(name="tasks.update_skill_offset")(compute_skill_offset) celery.task(name="tasks.update_lorenz_and_gini")(compute_lorenz_and_gini) +celery.task(name="tasks.fetch_weapon_leaderboard")(fetch_weapon_leaderboard) +celery.task(name="tasks.fetch_season_results")(fetch_season_results) diff --git a/src/celery_app/beat.py b/src/celery_app/beat.py index 5c22303..1207126 100644 --- a/src/celery_app/beat.py +++ b/src/celery_app/beat.py @@ -26,4 +26,12 @@ "task": "tasks.update_lorenz_and_gini", "schedule": crontab(minute="*/10"), }, + "fetch-weapon-leaderboard-every-ten-minutes": { + "task": "tasks.fetch_weapon_leaderboard", + "schedule": crontab(minute="5-59/10"), + }, + "fetch-season-results-every-hour": { + "task": "tasks.fetch_season_results", + "schedule": crontab(minute=30, hour="*"), + }, } diff --git a/src/celery_app/tasks/analytics/skill_offset.py b/src/celery_app/tasks/analytics/skill_offset.py index 34fb73e..8b3a69e 100644 --- a/src/celery_app/tasks/analytics/skill_offset.py +++ b/src/celery_app/tasks/analytics/skill_offset.py @@ -92,7 +92,9 @@ def compute_probability_map(sorted_xp_scaled: pd.Series) -> pd.DataFrame: prob_df.columns = [int(x) * 2 + 1 for x in range(prob_df.shape[1])] prob_df["y"] = sorted_xp_scaled.values prob_df["y_bin"] = pd.cut(prob_df["y"], bins=NUM_BINS) - prob_df_binned = prob_df.groupby("y_bin").sum().drop(columns="y") + prob_df_binned = ( + prob_df.groupby("y_bin", observed=False).sum().drop(columns="y") + ) df_sums = prob_df_binned.sum(axis=0) prob_df_logbin: pd.DataFrame = ( prob_df_binned.div(df_sums, axis=1) @@ -144,7 +146,7 @@ def subcompute_skill_offset( input_df[label] .sub(input_df["mode_bin_center"]) .gt(0) - .replace({True: 1, False: -1}) + .map({True: 1, False: -1}) .mul(diff) ) diff --git a/src/celery_app/tasks/leaderboard.py b/src/celery_app/tasks/leaderboard.py new file mode 100644 index 0000000..387c6ec --- /dev/null +++ b/src/celery_app/tasks/leaderboard.py @@ -0,0 +1,117 @@ +import logging + +import orjson +import pandas as pd +from sqlalchemy import text + +from celery_app.connections import Session, redis_conn +from shared_lib.constants import ( + SEASON_RESULTS_REDIS_KEY, + WEAPON_LEADERBOARD_PEAK_REDIS_KEY, +) +from shared_lib.queries.leaderboard_queries import ( + LIVE_WEAPON_LEADERBOARD_QUERY, + SEASON_RESULTS_QUERY, + WEAPON_LEADERBOARD_QUERY, +) +from shared_lib.utils import get_all_alt_kits + +logger = logging.getLogger(__name__) + +idx_columns = ["player_id", "season_number", "mode", "region"] + + +def fetch_past_weapon_leaderboard_data() -> pd.DataFrame: + """Fetches past weapon leaderboard data from the database. + + Returns: + pd.DataFrame: A DataFrame of weapon leaderboard data. + """ + logger.info("Fetching past weapon leaderboard data") + query = text(WEAPON_LEADERBOARD_QUERY) + with Session() as session: + result = session.execute(query).fetchall() + weapon_leaderboard = pd.DataFrame( + [{**row._asdict()} for row in result] + ).set_index(idx_columns) + + return weapon_leaderboard + + +def fetch_live_weapon_leaderboard_data() -> pd.DataFrame: + """Fetches live weapon leaderboard data from the database. + + Returns: + pd.DataFrame: A DataFrame of weapon leaderboard data. + """ + logger.info("Fetching live weapon leaderboard data") + query = text(LIVE_WEAPON_LEADERBOARD_QUERY) + with Session() as session: + result = session.execute(query).fetchall() + weapon_leaderboard = pd.DataFrame( + [{**row._asdict()} for row in result] + ).set_index(idx_columns) + + weapon_leaderboard["weapon_id"] = ( + weapon_leaderboard["weapon_id"] + .astype(str) + .map(get_all_alt_kits()) + .fillna(weapon_leaderboard["weapon_id"]) + .astype(str) + ) + + total_games_df = ( + weapon_leaderboard.reset_index() + .groupby(idx_columns)["games_played"] + .sum() + .rename("total_games_played") + ) + weapon_leaderboard = weapon_leaderboard.merge( + total_games_df, left_index=True, right_index=True, how="left" + ) + weapon_leaderboard["percent_games_played"] = weapon_leaderboard[ + "games_played" + ].div(weapon_leaderboard["total_games_played"]) + + return ( + weapon_leaderboard.groupby(idx_columns + ["weapon_id"]) + .agg( + { + "max_x_power": "max", + "games_played": "sum", + "percent_games_played": "sum", + } + ) + .reset_index() + .set_index(idx_columns) + ) + + +def fetch_weapon_leaderboard() -> pd.DataFrame: + logger.info("Fetching weapon data") + past_weapon_leaderboard = fetch_past_weapon_leaderboard_data() + live_weapon_leaderboard = fetch_live_weapon_leaderboard_data() + weapon_leaderboard = pd.concat( + [past_weapon_leaderboard, live_weapon_leaderboard] + ).sort_index() + del past_weapon_leaderboard, live_weapon_leaderboard + + redis_conn.set( + WEAPON_LEADERBOARD_PEAK_REDIS_KEY, + orjson.dumps( + weapon_leaderboard.reset_index().to_dict(orient="records") + ), + ) + + +def fetch_season_results() -> pd.DataFrame: + logger.info("Fetching season results") + query = text(SEASON_RESULTS_QUERY) + with Session() as session: + result = session.execute(query).fetchall() + season_results = pd.DataFrame([{**row._asdict()} for row in result]) + + redis_conn.set( + SEASON_RESULTS_REDIS_KEY, + orjson.dumps(season_results.to_dict(orient="records")), + ) diff --git a/src/fast_api_app/app.py b/src/fast_api_app/app.py index 5994b1b..cdecef6 100644 --- a/src/fast_api_app/app.py +++ b/src/fast_api_app/app.py @@ -17,6 +17,7 @@ player_detail_router, search_router, weapon_info_router, + weapon_leaderboard_router, ) # Setup basic logging @@ -34,6 +35,8 @@ async def lifespan(app: FastAPI): celery.send_task("tasks.pull_aliases") celery.send_task("tasks.update_skill_offset") celery.send_task("tasks.update_lorenz_and_gini") + celery.send_task("tasks.fetch_weapon_leaderboard") + celery.send_task("tasks.fetch_season_results") start_pubsub_listener() asyncio.create_task(background_runner.run()) @@ -63,6 +66,7 @@ async def lifespan(app: FastAPI): app.include_router(player_detail_router) app.include_router(search_router) app.include_router(weapon_info_router) +app.include_router(weapon_leaderboard_router) # Base route that lists all available routes diff --git a/src/fast_api_app/background_tasks.py b/src/fast_api_app/background_tasks.py index dd1c159..e42e253 100644 --- a/src/fast_api_app/background_tasks.py +++ b/src/fast_api_app/background_tasks.py @@ -1,25 +1,69 @@ import asyncio import logging -from fast_api_app.memory_sqlite import update_database +from fast_api_app.sqlite_tables import ( + AliasManager, + SeasonResultsManager, + TableManager, + WeaponLeaderboardManager, +) +from shared_lib.constants import ( + ALIASES_REDIS_KEY, + SEASON_RESULTS_REDIS_KEY, + WEAPON_LEADERBOARD_PEAK_REDIS_KEY, +) logger = logging.getLogger(__name__) class BackgroundRunner: + def __init__(self, table_managers: list[TableManager]): + self.table_managers = table_managers + for manager in self.table_managers: + manager.initialize_table() + + async def update_table(self, manager: TableManager): + logger.info("Updating table %s", manager.table_name) + sleep_time = manager.cadence + try: + manager.update_database() + except Exception as e: + logger.error(f"Error updating table {manager.table_name}: {e}") + sleep_time = manager.retry_cadence + + logger.info( + "Sleeping %s for %d seconds", manager.table_name, sleep_time + ) + await asyncio.sleep(sleep_time) + async def run(self): - logger.info("Starting background task to update aliases database") + logger.info("Starting background task to update tables") while True: - logger.info("Updating aliases database") - sleep_time = 600 - try: - update_database() - except Exception as e: - logger.error(f"Error updating aliases database: {e}") - sleep_time = 60 - - logger.info("Sleeping for %d seconds", sleep_time) - await asyncio.sleep(sleep_time) + tasks = [ + self.update_table(manager) for manager in self.table_managers + ] + await asyncio.gather(*tasks) -background_runner = BackgroundRunner() +background_runner = BackgroundRunner( + [ + AliasManager( + "aliases", + ALIASES_REDIS_KEY, + cadence=600, + retry_cadence=60, + ), + WeaponLeaderboardManager( + "weapon_leaderboard_peak", + WEAPON_LEADERBOARD_PEAK_REDIS_KEY, + cadence=600, + retry_cadence=60, + ), + SeasonResultsManager( + "season_results", + SEASON_RESULTS_REDIS_KEY, + cadence=600, + retry_cadence=60, + ), + ] +) diff --git a/src/fast_api_app/memory_sqlite.py b/src/fast_api_app/memory_sqlite.py deleted file mode 100644 index 1f062ca..0000000 --- a/src/fast_api_app/memory_sqlite.py +++ /dev/null @@ -1,57 +0,0 @@ -import logging - -import orjson - -from fast_api_app.connections import redis_conn, sqlite_conn, sqlite_cursor -from shared_lib.constants import ALIASES_REDIS_KEY, AUTOMATON_IS_VALID_REDIS_KEY - -logger = logging.getLogger(__name__) - - -def initialize_database() -> None: - sqlite_cursor.execute( - "CREATE TABLE IF NOT EXISTS player_data (key TEXT, value TEXT);" - ) - sqlite_conn.commit() - - -def insert_data(key: str, value: str) -> None: - sqlite_cursor.execute( - "INSERT INTO player_data (key, value) VALUES (?, ?);", - (key, value), - ) - sqlite_conn.commit() - - -def search_data(key: str) -> list: - formatted_key = f"%{key}%" - logger.info(f"Searching for: {formatted_key}") - sqlite_cursor.execute( - "SELECT key, value FROM player_data WHERE key LIKE ?", - (formatted_key,), - ) - logger.info(f"Search complete for: {formatted_key}") - return sqlite_cursor.fetchall() - - -def update_database() -> None: - aliases_data = redis_conn.get(ALIASES_REDIS_KEY) - if aliases_data: - aliases = orjson.loads(aliases_data) - # Clear existing data - sqlite_cursor.execute("DELETE FROM player_data;") - for player_dict in aliases: - alias = player_dict["splashtag"] - player_id = player_dict["player_id"] - insert_data(alias, player_id) - - sqlite_conn.commit() - logger.info("SQLite database updated with new aliases") - - redis_conn.set(AUTOMATON_IS_VALID_REDIS_KEY, 1, ex=3600) - else: - logger.warning("Aliases data not found in Redis") - raise Exception("Aliases data not found in Redis") - - -initialize_database() diff --git a/src/fast_api_app/routes/__init__.py b/src/fast_api_app/routes/__init__.py index 5c6db3f..4b33e6b 100644 --- a/src/fast_api_app/routes/__init__.py +++ b/src/fast_api_app/routes/__init__.py @@ -2,3 +2,6 @@ from fast_api_app.routes.player_detail import router as player_detail_router from fast_api_app.routes.search import router as search_router from fast_api_app.routes.weapon_info import router as weapon_info_router +from fast_api_app.routes.weapon_leaderboard import ( + router as weapon_leaderboard_router, +) diff --git a/src/fast_api_app/routes/front_page.py b/src/fast_api_app/routes/front_page.py index 3958705..fa74652 100644 --- a/src/fast_api_app/routes/front_page.py +++ b/src/fast_api_app/routes/front_page.py @@ -2,7 +2,6 @@ from fastapi import APIRouter, HTTPException, Query from fast_api_app.connections import redis_conn -from shared_lib.constants import MODES router = APIRouter() @@ -14,9 +13,7 @@ async def leaderboard( ), region: str = Query("Tentatek", description="Region for the leaderboard"), ): - region_bool = "Takoroka" if region == "Takoroka" else "Tentatek" - - redis_key = f"leaderboard_data:{mode}:{region_bool}" + redis_key = f"leaderboard_data:{mode}:{region}" players = redis_conn.get(redis_key) if players is None: diff --git a/src/fast_api_app/routes/search.py b/src/fast_api_app/routes/search.py index 83bf1be..6ada3b3 100644 --- a/src/fast_api_app/routes/search.py +++ b/src/fast_api_app/routes/search.py @@ -2,8 +2,7 @@ from fastapi import APIRouter, HTTPException, Request -from fast_api_app.connections import limiter, redis_conn -from fast_api_app.memory_sqlite import search_data +from fast_api_app.connections import limiter, redis_conn, sqlite_cursor from shared_lib.constants import AUTOMATON_IS_VALID_REDIS_KEY router = APIRouter() @@ -20,4 +19,10 @@ async def search(query: str, request: Request): ) logger.info(f"Searching for: {query}") - return search_data(query)[:10] + formatted_key = f"%{query}%" + sqlite_cursor.execute( + "SELECT alias, player_id FROM aliases WHERE alias LIKE ? LIMIT 10", + (formatted_key,), + ) + logger.info(f"Search complete for: {query}") + return sqlite_cursor.fetchall() diff --git a/src/fast_api_app/routes/weapon_leaderboard.py b/src/fast_api_app/routes/weapon_leaderboard.py new file mode 100644 index 0000000..8f827bf --- /dev/null +++ b/src/fast_api_app/routes/weapon_leaderboard.py @@ -0,0 +1,104 @@ +import logging + +from fastapi import APIRouter, HTTPException, Query + +from fast_api_app.connections import sqlite_cursor +from shared_lib.queries.leaderboard_queries import ( + SEASON_RESULTS_SQLITE_QUERY, + WEAPON_LEADERBOARD_SQLITE_QUERY, +) +from shared_lib.utils import get_weapon_image + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/api/weapon_leaderboard/{weapon_id}") +async def weapon_leaderboard( + weapon_id: int, + mode: str = Query( + "Splat Zones", description="Game mode for the leaderboard" + ), + region: str = Query("Tentatek", description="Region for the leaderboard"), + additional_weapon_id: int = Query( + None, description="Additional weapon id for comparison" + ), + min_threshold: int = Query( + 500, description="Minimum threshold for showing a weapon, 500 means 50%" + ), + final_results: bool = Query( + False, description="Whether to show final results or not" + ), +): + logger.info( + "Fetching weapon leaderboard for weapon_id: %d, " + "mode: %s, region: %s, additional_weapon_id: %s, min_threshold: %d, " + "final_results: %s", + weapon_id, + mode, + region, + additional_weapon_id, + min_threshold, + final_results, + ) + + region_bool = region.lower() == "takoroka" + min_threshold /= 1000 + + if final_results: + query = SEASON_RESULTS_SQLITE_QUERY + params = { + "mode": mode, + "region": int(region_bool), + "min_threshold": min_threshold, + "weapon_id": weapon_id, + "additional_weapon_id": additional_weapon_id, + } + else: + query = WEAPON_LEADERBOARD_SQLITE_QUERY + params = { + "mode": mode, + "region": int(region_bool), + "min_threshold": min_threshold, + "weapon_id": weapon_id, + "additional_weapon_id": additional_weapon_id, + } + + results = sqlite_cursor.execute(query, params) + result = results.fetchall() + if not result: + check_if_data_available = sqlite_cursor.execute( + "SELECT COUNT(*) FROM weapon_leaderboard_peak" + ) + if not check_if_data_available.fetchone()[0]: + logger.error( + "No data found for weapon_id: %d, mode: %s, region: %s, " + "additional_weapon_id: %s, min_threshold: %d, final_results: %s", + weapon_id, + mode, + region, + additional_weapon_id, + min_threshold, + final_results, + ) + raise HTTPException( + status_code=503, + detail="Data is not available yet, please wait.", + ) + else: + logger.info("No data found for weapon_id: %d", weapon_id) + return {"players": {}, "mode": mode, "region": bool(region)} + + columns = [desc[0] for desc in sqlite_cursor.description] + out = {"players": {}} + for column in columns: + if column in ["mode", "region"]: + continue + out["players"][column] = [row[columns.index(column)] for row in result] + out["weapon_image"] = get_weapon_image(weapon_id) + if additional_weapon_id: + out["additional_weapon_image"] = get_weapon_image(additional_weapon_id) + out["mode"] = mode + out["region"] = bool(region) + return out diff --git a/src/fast_api_app/sqlite_tables/__init__.py b/src/fast_api_app/sqlite_tables/__init__.py new file mode 100644 index 0000000..742ea1b --- /dev/null +++ b/src/fast_api_app/sqlite_tables/__init__.py @@ -0,0 +1,6 @@ +from fast_api_app.sqlite_tables.leaderboard import ( + SeasonResultsManager, + WeaponLeaderboardManager, +) +from fast_api_app.sqlite_tables.main import TableManager +from fast_api_app.sqlite_tables.search import AliasManager diff --git a/src/fast_api_app/sqlite_tables/leaderboard.py b/src/fast_api_app/sqlite_tables/leaderboard.py new file mode 100644 index 0000000..f2c32c9 --- /dev/null +++ b/src/fast_api_app/sqlite_tables/leaderboard.py @@ -0,0 +1,196 @@ +import logging + +import orjson + +from fast_api_app.connections import redis_conn, sqlite_conn, sqlite_cursor +from fast_api_app.sqlite_tables.main import TableManager + +logger = logging.getLogger(__name__) + + +class WeaponLeaderboardManager(TableManager): + def initialize_table(self) -> None: + sqlite_cursor.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.table_name} ( + player_id TEXT, + season_number INTEGER, + mode TEXT, + region BOOLEAN, + weapon_id INTEGER, + max_x_power REAL, + games_played INTEGER, + percent_games_played REAL, + PRIMARY KEY (player_id, season_number, mode, region, weapon_id) + ); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_player_id + ON {self.table_name} (player_id); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_season_number + ON {self.table_name} (season_number); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_mode + ON {self.table_name} (mode); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_region + ON {self.table_name} (region); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_weapon_id + ON {self.table_name} (weapon_id); + """ + ) + sqlite_conn.commit() + + def insert_data(self, data: dict) -> None: + sqlite_cursor.execute( + f""" + INSERT INTO {self.table_name} ( + player_id, + season_number, + mode, + region, + weapon_id, + max_x_power, + games_played, + percent_games_played + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?); + """, + ( + data["player_id"], + data["season_number"], + data["mode"], + data["region"], + data["weapon_id"], + data["max_x_power"], + data["games_played"], + data["percent_games_played"], + ), + ) + sqlite_conn.commit() + + def update_database(self) -> None: + logger.info("Updating SQLite table %s", self.table_name) + weapon_leaderboard_peak_data = redis_conn.get(self.redis_key) + if weapon_leaderboard_peak_data: + weapon_leaderboard_peak = orjson.loads(weapon_leaderboard_peak_data) + sqlite_cursor.execute(f"DELETE FROM {self.table_name};") + for player_dict in weapon_leaderboard_peak: + self.insert_data(player_dict) + sqlite_conn.commit() + logger.info( + "SQLite database updated with new weapon leaderboard peak data " + "with %d rows", + len(weapon_leaderboard_peak), + ) + else: + logger.warning("Weapon leaderboard peak data not found in Redis") + raise Exception("Weapon leaderboard peak data not found in Redis") + + +class SeasonResultsManager(TableManager): + def initialize_table(self) -> None: + sqlite_cursor.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.table_name} ( + player_id TEXT, + season_number INTEGER, + mode TEXT, + region BOOLEAN, + weapon_id INTEGER, + x_power REAL, + rank INTEGER, + PRIMARY KEY (player_id, season_number, mode, region) + ); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_player_id + ON {self.table_name} (player_id); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_season_number + ON {self.table_name} (season_number); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_mode + ON {self.table_name} (mode); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_region + ON {self.table_name} (region); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_weapon_id + ON {self.table_name} (weapon_id); + """ + ) + sqlite_conn.commit() + + def insert_data(self, data: dict) -> None: + sqlite_cursor.execute( + f""" + INSERT INTO {self.table_name} ( + player_id, + season_number, + mode, + region, + weapon_id, + x_power, + rank + ) + VALUES (?, ?, ?, ?, ?, ?, ?); + """, + ( + data["player_id"], + data["season_number"], + data["mode"], + data["region"], + data["weapon_id"], + data["x_power"], + data["rank"], + ), + ) + + def update_database(self) -> None: + logger.info("Updating SQLite table %s", self.table_name) + season_results_data = redis_conn.get(self.redis_key) + if season_results_data: + season_results = orjson.loads(season_results_data) + sqlite_cursor.execute(f"DELETE FROM {self.table_name};") + for player_dict in season_results: + self.insert_data(player_dict) + sqlite_conn.commit() + logger.info( + "SQLite database updated with new season results data with " + "%d rows", + len(season_results), + ) + else: + logger.warning("Season results data not found in Redis") + raise Exception("Season results data not found in Redis") diff --git a/src/fast_api_app/sqlite_tables/main.py b/src/fast_api_app/sqlite_tables/main.py new file mode 100644 index 0000000..0898df5 --- /dev/null +++ b/src/fast_api_app/sqlite_tables/main.py @@ -0,0 +1,32 @@ +import logging +from abc import ABC, abstractmethod + +logger = logging.getLogger(__name__) + + +class TableManager(ABC): + def __init__( + self, + table_name: str, + redis_key: str, + cadence: int = 600, + retry_cadence: int = 60, + ): + logger.info(f"Initializing TableManager for {table_name}") + self.table_name = table_name + self.redis_key = redis_key + self.cadence = cadence + self.retry_cadence = retry_cadence + self.initialize_table() + + @abstractmethod + def initialize_table(self) -> None: + pass + + @abstractmethod + def insert_data(self, data: dict) -> None: + pass + + @abstractmethod + def update_database(self) -> None: + pass diff --git a/src/fast_api_app/sqlite_tables/search.py b/src/fast_api_app/sqlite_tables/search.py new file mode 100644 index 0000000..8f0d43e --- /dev/null +++ b/src/fast_api_app/sqlite_tables/search.py @@ -0,0 +1,72 @@ +import logging + +import orjson + +from fast_api_app.connections import redis_conn, sqlite_conn, sqlite_cursor +from fast_api_app.sqlite_tables.main import TableManager +from shared_lib.constants import AUTOMATON_IS_VALID_REDIS_KEY + +logger = logging.getLogger(__name__) + + +class AliasManager(TableManager): + def initialize_table(self) -> None: + sqlite_cursor.execute( + f""" + CREATE TABLE IF NOT EXISTS {self.table_name} ( + alias TEXT, + player_id TEXT, + last_seen DATETIME + ); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_alias + ON {self.table_name} (alias); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_player_id + ON {self.table_name} (player_id); + """ + ) + sqlite_cursor.execute( + f""" + CREATE INDEX IF NOT EXISTS idx_{self.table_name}_last_seen + ON {self.table_name} (last_seen); + """ + ) + sqlite_conn.commit() + + def insert_data(self, data: dict) -> None: + sqlite_cursor.execute( + f""" + INSERT INTO {self.table_name} (alias, player_id, last_seen) + VALUES (?, ?, ?); + """, + (data["alias"], data["player_id"], data["last_seen"]), + ) + sqlite_conn.commit() + + def update_database(self) -> None: + aliases_data = redis_conn.get(self.redis_key) + if aliases_data: + aliases = orjson.loads(aliases_data) + sqlite_cursor.execute(f"DELETE FROM {self.table_name};") + for player_dict in aliases: + self.insert_data( + { + "alias": player_dict["splashtag"], + "player_id": player_dict["player_id"], + "last_seen": player_dict["last_seen"], + } + ) + + sqlite_conn.commit() + logger.info("SQLite database updated for %s", self.table_name) + redis_conn.set(AUTOMATON_IS_VALID_REDIS_KEY, 1, ex=3600) + else: + logger.warning("Data not found in Redis for key %s", self.redis_key) + raise Exception(f"Data not found in Redis for key {self.redis_key}") diff --git a/src/react_app/package.json b/src/react_app/package.json index b384951..82ffc10 100644 --- a/src/react_app/package.json +++ b/src/react_app/package.json @@ -17,6 +17,7 @@ "pako": "^2.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-draggable": "^4.4.6", "react-i18next": "^14.1.1", "react-icons": "^5.2.1", "react-router-dom": "^6.22.3", diff --git a/src/react_app/public/locales/EUen/main_page.json b/src/react_app/public/locales/EUen/main_page.json index 8abf446..788a46a 100644 --- a/src/react_app/public/locales/EUen/main_page.json +++ b/src/react_app/public/locales/EUen/main_page.json @@ -19,5 +19,15 @@ "column_total_x_power_title": "Total X Power", "column_peak_xpower_title": "Peak X Power", "column_percent_games_played_title": "% Games Played", - "column_season_number_title": "Season" + "column_season_number_title": "Season", + "weapon_select_main": "Weapon Select", + "weapon_select_alt": "Weapon Select (Alt)", + "weapon_title": "Top Weapon Wielders", + "threshold_select": "Minimum Percent Usage", + "weapon_leaderboard.peak_x_power": "Peak X Power", + "weapon_leaderboard.final_x_power": "Season End X Power", + "select_weapon": "Select Weapon", + "null_selection": "No Alt Weapon", + "no_data": "No data available", + "errors.503": "Service Unavailable, please try again later." } \ No newline at end of file diff --git a/src/react_app/public/locales/EUen/navigation.json b/src/react_app/public/locales/EUen/navigation.json index 0ff2b1c..1bdc239 100644 --- a/src/react_app/public/locales/EUen/navigation.json +++ b/src/react_app/public/locales/EUen/navigation.json @@ -4,5 +4,6 @@ "footer.contact": "Contact", "footer.rights": "%DATE% splat.top. All rights reserved.", "navbar.faq": "FAQ", - "navbar.analytics": "Analytics" + "navbar.analytics": "Analytics", + "navbar.top_weapons": "Top Weapons" } \ No newline at end of file diff --git a/src/react_app/public/locales/USen/main_page.json b/src/react_app/public/locales/USen/main_page.json index afe9ab3..acee533 100644 --- a/src/react_app/public/locales/USen/main_page.json +++ b/src/react_app/public/locales/USen/main_page.json @@ -14,10 +14,20 @@ "column_weapon_not_supported": "Weapon name not yet supported", "column_xpower_title": "X Power", "column_peak_xpower_title": "Peak X Power", - "column_percent_games_played_title": "% Games Played", + "column_percent_games_played_title": "Est. % Usage", "column_season_number_title": "Season", "select_columns": "Select columns", "modes": "Modes", "region": "Region", - "column_total_x_power_title": "Total X Power" + "column_total_x_power_title": "Total X Power", + "weapon_select_main": "Weapon Select", + "weapon_select_alt": "Weapon Select (Alt)", + "weapon_title": "Top Weapon Wielders", + "threshold_select": "Minimum Percent Usage", + "weapon_leaderboard.peak_x_power": "Peak X Power", + "weapon_leaderboard.final_x_power": "Season End X Power", + "select_weapon": "Select Weapon", + "null_selection": "No Alt Weapon", + "no_data": "No data available", + "errors.503": "Service Unavailable, please try again later." } \ No newline at end of file diff --git a/src/react_app/public/locales/USen/navigation.json b/src/react_app/public/locales/USen/navigation.json index 0ff2b1c..1bdc239 100644 --- a/src/react_app/public/locales/USen/navigation.json +++ b/src/react_app/public/locales/USen/navigation.json @@ -4,5 +4,6 @@ "footer.contact": "Contact", "footer.rights": "%DATE% splat.top. All rights reserved.", "navbar.faq": "FAQ", - "navbar.analytics": "Analytics" + "navbar.analytics": "Analytics", + "navbar.top_weapons": "Top Weapons" } \ No newline at end of file diff --git a/src/react_app/public/locales/USes/main_page.json b/src/react_app/public/locales/USes/main_page.json index 1529206..afbde14 100644 --- a/src/react_app/public/locales/USes/main_page.json +++ b/src/react_app/public/locales/USes/main_page.json @@ -19,5 +19,15 @@ "column_total_x_power_title": "Fuerza X Total", "column_peak_xpower_title": "Peak X Power", "column_percent_games_played_title": "% Games Played", - "column_season_number_title": "Season" + "column_season_number_title": "Season", + "weapon_select_main": "Weapon Select", + "weapon_select_alt": "Weapon Select (Alt)", + "weapon_title": "Top Weapon Wielders", + "threshold_select": "Minimum Percent Usage", + "weapon_leaderboard.peak_x_power": "Peak X Power", + "weapon_leaderboard.final_x_power": "Season End X Power", + "select_weapon": "Select Weapon", + "null_selection": "No Alt Weapon", + "no_data": "No data available", + "errors.503": "Service Unavailable, please try again later." } \ No newline at end of file diff --git a/src/react_app/public/locales/USes/navigation.json b/src/react_app/public/locales/USes/navigation.json index cabe26a..a2b0bc9 100644 --- a/src/react_app/public/locales/USes/navigation.json +++ b/src/react_app/public/locales/USes/navigation.json @@ -4,5 +4,6 @@ "footer.contact": "Contacto", "footer.rights": "%DATE% splat.top. Todos los derechos reservados.", "navbar.faq": "FAQ", - "navbar.analytics": "Analítica" + "navbar.analytics": "Analítica", + "navbar.top_weapons": "Top Weapons" } \ No newline at end of file diff --git a/src/react_app/src/App.js b/src/react_app/src/App.js index d54a673..628f891 100644 --- a/src/react_app/src/App.js +++ b/src/react_app/src/App.js @@ -9,6 +9,9 @@ const FAQ = React.lazy(() => import("./components/static_pages/faq")); const About = React.lazy(() => import("./components/static_pages/about")); const PlayerDetail = React.lazy(() => import("./components/player_detail")); const Analytics = React.lazy(() => import("./components/analytics")); +const TopWeapons = React.lazy(() => + import("./components/weapon_leaderboard") +); const App = () => { return ( @@ -22,6 +25,7 @@ const App = () => { } /> } /> } /> + } />