diff --git a/cogs/commands/__init__.py b/cogs/commands/__init__.py index 05bb85d..21a0f0c 100644 --- a/cogs/commands/__init__.py +++ b/cogs/commands/__init__.py @@ -10,6 +10,7 @@ from .help import HelpCog from .refresh import RefreshCog from .set import SetCog from .update import UpdateCog +from .view import ViewCog # This function will be called when this extension is loaded. @@ -24,3 +25,4 @@ def setup(bot: commands.Bot): bot.add_cog(RefreshCog(bot)) bot.add_cog(SetCog()) bot.add_cog(UpdateCog(bot)) + bot.add_cog(ViewCog()) diff --git a/cogs/commands/view.py b/cogs/commands/view.py new file mode 100644 index 0000000..148abe9 --- /dev/null +++ b/cogs/commands/view.py @@ -0,0 +1,112 @@ +""" +View command heirarchy. +""" + +import nextcord +import pandas as pd +from nextcord.ext import commands +from sqlalchemy import select +from sqlalchemy.orm import Session + +# pylint: disable=import-error +from utilities.sql_data_classes import Category, CategoryGroup, Score, engine +from utilities.utils import MAX_LINE_LENGTH, autofill_categories, autofill_groups, format_score_string + + +class ViewCog(commands.Cog, name="View Command"): + """ + Class holds commands for viewing data. + """ + + def __init__(self) -> None: + """ + Default constructor for ViewCog class. Does nothing. + """ + + @nextcord.slash_command() + async def view(self, interaction: nextcord.Interaction): + """ + Parent slash command for view. Never called. + """ + + @view.subcommand(description="Lists all scores for a given category.") + async def leaderboard( + self, + interaction: nextcord.Interaction, + group_id: str = nextcord.SlashOption( + name="group", description="Group to show leaderboard from.", required=True + ), + category_id: str = nextcord.SlashOption( + name="category", description="Category to show leaderboard.", required=True + ), + ): + """ + Gathers all users that have a submission in the group/category combo and returns a message with them + in ranked order. + + Args: + interaction (nextcord.Interaction): Interaction that invoked the command. + group_id (str): String representation of Group id to search from. Is SlashOption. + category_id (str): String representation of Category id to show leaderboard. Is SlashOption. + """ + str_list: "list[str]" = [] + + # get group, category, and all scores sorted correctly. + with Session(engine) as session: + stmt = select(CategoryGroup).where(CategoryGroup.GroupId == int(group_id)) + group = session.scalar(stmt) + + stmt = select(Category).where(Category.CategoryId == int(category_id)) + category = session.scalar(stmt) + + d_f = pd.read_sql_query( + sql=session.query(Score).where(Score.CategoryId == category_id).statement, con=engine + ) + + scores = d_f.sort_values("Score", ascending=category.IsAscending).reset_index() + + # get the longest string + longest_str: int = scores.UserName.map(len).max() + + for i, row in scores.iterrows(): + str_list.append( + format_score_string( + row["UserName"], row["Score"], longest_str, category.IsTimeRecord, i + 1, MAX_LINE_LENGTH + ) + ) + + message_str: str = f"**{group.GroupName} {category.CategoryName} Leaderboard!**\n```" + if len(str_list) == 0: + message_str += "No Submissions!" + else: + for line in str_list: + message_str += f"{line}\n" + + message_str += "```" + + await interaction.response.send_message(content=message_str) + + @leaderboard.on_autocomplete("group_id") + async def group_autocomplete(self, interaction: nextcord.Interaction, group_id: str): + """ + Autocomplete function for table names. + + Args: + interaction (nextcord.Interaction): Interaction that invoked the command. + group_id (str): String to be autocompleted with Group name. + """ + await interaction.response.send_autocomplete(autofill_groups(interaction.guild_id, group_id)) + + @leaderboard.on_autocomplete("category_id") + async def category_autocomplete(self, interaction: nextcord.Interaction, category_id: str, group_id: str): + """ + Autocomplete function for category names. + + Args: + interaction (nextcord.Interaction): Interaction that invoked the command. + category_id (str): String to be autocompleted with Category name. + group_id (str): Group to find Category from. + """ + await interaction.response.send_autocomplete( + autofill_categories(interaction.guild_id, int(group_id), category_id) + ) diff --git a/highscores_database.sql b/highscores_database.sql index 74dfc59..ae0a0f6 100644 --- a/highscores_database.sql +++ b/highscores_database.sql @@ -17,7 +17,7 @@ CREATE TABLE CategoryGroups( PadLength smallint not null DEFAULT 26, UsernameLength smallint DEFAULT 12, CONSTRAINT UniqueGuildCategoryGroup UNIQUE (GuildId, GroupName), - FOREIGN KEY (GuildId) REFERENCES Guilds(GuildId), + FOREIGN KEY (GuildId) REFERENCES Guilds(GuildId) ON DELETE CASCADE, PRIMARY KEY (GroupId) ); @@ -30,7 +30,7 @@ CREATE TABLE Categories( IsTimeRecord boolean not null DEFAULT false, IsValidated boolean not null DEFAULT true, CONSTRAINT UniqueCategoryInGroup UNIQUE (GroupId, CategoryName), - FOREIGN KEY (GroupId) REFERENCES CategoryGroups(GroupId), + FOREIGN KEY (GroupId) REFERENCES CategoryGroups(GroupId) ON DELETE CASCADE, PRIMARY Key (CategoryId) ); @@ -38,6 +38,6 @@ CREATE TABLE Scores( UserName varchar(30) not null, Score float not null, -- Maybe should have time and int scores?? timestamp type? CategoryId int unsigned not null, - FOREIGN KEY (CategoryId) REFERENCES Categories(CategoryId), + FOREIGN KEY (CategoryId) REFERENCES Categories(CategoryId) ON DELETE CASCADE, PRIMARY KEY (CategoryId, UserName) ); \ No newline at end of file diff --git a/utilities/utils.py b/utilities/utils.py index be41bbb..0ade38c 100644 --- a/utilities/utils.py +++ b/utilities/utils.py @@ -231,20 +231,12 @@ def format_highscore_message(session: Session, group: CategoryGroup) -> str: # add each score to list for ind in range(max_cat_size): # need to subtract to deal with category name string if ind in processed_group.index: - user_length = group.UsernameLength - username = processed_group["UserName"][ind][:user_length].ljust(user_length, " ") + username = processed_group["UserName"][ind] + score = processed_group["Score"][ind] - # get score, pad to correct length - if category.IsTimeRecord: - # MM:SS.ms is 7 characters long - # if there are single digit minutes, add a space for formatting. - score = get_time_str(processed_group["Score"][ind]) - if score.find(":") and len(score.split(":", maxsplit=1)[0]) < 2: - score = " " + score - else: - score = str(int(processed_group["Score"][ind])).rjust(7, " ") - - str_list[ind + 1] += f"{ind + 1}: {username} - {score}".ljust(pad_length, " ") + str_list[ind + 1] += format_score_string( + username, score, group.UsernameLength, category.IsTimeRecord, ind + 1, pad_length + ) else: # if not enough entries, pad out if ind + 1 <= category.Size: @@ -262,7 +254,6 @@ def format_highscore_message(session: Session, group: CategoryGroup) -> str: # need to use index to allow for modifying the list for ind, line in enumerate(str_list): if len(line) > MAX_LINE_LENGTH: - split_loc = int(MAX_LINE_LENGTH / pad_length) * pad_length new_line = line[split_loc:] str_list[ind] = line[:split_loc] @@ -283,6 +274,38 @@ def format_highscore_message(session: Session, group: CategoryGroup) -> str: return ret_string + "```" +def format_score_string( + username: str, score: float, username_length: int, is_time_record: bool, rank: int, pad_length: int +) -> str: + """ + Builds the string to show a user's score. + + Args: + username (str): username. + score (int): score in int form. + username_length (float): limit to how long username is allowed to be. + is_time_record (bool): if the score represents an number or a time record. + rank (int): the user's rank. + pad_length (int): limit to how long the string can be. + + Returns: + str: string to show user's score. + """ + username = username[:username_length].ljust(username_length, " ") + + # get score, pad to correct length + if is_time_record: + # MM:SS.ms is 7 characters long + # if there are single digit minutes, add a space for formatting. + score = get_time_str(score) + if score.find(":") and len(score.split(":", maxsplit=1)[0]) < 2: + score = " " + score + else: + score = str(int(score)).rjust(7, " ") + + return f"{rank}: {username} - {score}".ljust(pad_length, " ") + + def get_time_str(time_in: float) -> str: """ Formats a float value into the desired string format for a timestamp.