diff --git a/cogs/commands/submission.py b/cogs/commands/submission.py new file mode 100644 index 0000000..2266e71 --- /dev/null +++ b/cogs/commands/submission.py @@ -0,0 +1,218 @@ +"""Contains command for submitting a score.""" + +import re + +import nextcord +from nextcord.ext import commands +from sqlalchemy import select +from sqlalchemy.orm import Session + +from cogs.submission.verification_view import VerificationView +from database_setup.sql_data_classes import Category, CategoryGroup, Guild, Submission, engine +from utilities.utils import autofill_categories, autofill_groups, submit_score + + +class SubmissionCog(commands.Cog): + """Class holds command for submitting a score.""" + + def __init__(self) -> None: + return + + @nextcord.slash_command() + async def submit( + self, + interaction: nextcord.Interaction, + group_id: str = nextcord.SlashOption(name="Group", required=True, description="Group to submit score to."), + category_id: str = nextcord.SlashOption( + name="Category", required=True, description="Category to submit score to." + ), + score: str = nextcord.SlashOption(required=True, description="Score to submit"), + username: str = nextcord.SlashOption( + required=False, description="Modified username if submitting under not your discord name." + ), + ) -> None: + """ + Submit a highscore. + + Sets the username if one isn't passed in. Checks if a submission needs verified. Ensures that the score is + following the correct format. Then either creates the verification request or submits score if verification + isn't required. + + Args: + interaction (nextcord.Interaction): Interaction of the command. + group_id (str): Is SlashOption. Group that the score category belongs in. + category_id (str): Is SlashOption. Category to submit the score in. + score (str): Is SlashOption. The score. + username (str, optional): Is SlashOption. Username to associate with the score. + """ + # fill out username if not passed in + if username is None: + username = interaction.user.display_name + + with Session(engine) as session: + stmt = ( + select(Category).where(Category.GroupId == int(group_id)).where(Category.CategoryId == int(category_id)) + ) + + category: Category = session.scalar(stmt) + + # check if verification is required. if no picture, deny + if category.IsValidated and not interaction.message.attachments > 0: + await interaction.response.send_message( + "This category required verification. Please attach a picture and re-submit.", ephemeral=True + ) + return + + # check with regex if score is in correct format + score = await self.verify_score(interaction, score, is_time_record=category.IsTimeRecord) + + # check if verify_score was successful. It replies with corrections required itself. + if score is None: + return + + # Create submission verification request or submit score if verification not needed. + if not category.IsValidated: + await submit_score(interaction, group_id, category_id, username, score) + await interaction.response.send_message("Score has been submitted!", ephemeral=True) + return + + stmt = select(Guild.VerificationChannel).where(Guild.GuildId == interaction.guild_id) + verification_channel_id = session.scalar(stmt) + + channel = interaction.guild.get_channel(verification_channel_id) + + if channel is None: + error_response = ( + "Registered Verification channel does not exist or was never registered. Contact server admins." + ) + await interaction.response.send_message(error_response, ephemeral=True) + + # Send the verification message to admins + verification_message = await channel.send( + files=[await attach.to_file() for attach in interaction.message.attachments], + view=VerificationView(), + embeds=self.create_embed(interaction, group_id, category_id, score, username, session), + content=f"Submission from <@{interaction.user.id}>.", + ) + await interaction.response.send_message(content="Score submitted!", ephemeral=True) + + # Save the submission data for later use. + submission: Submission = Submission( + CategoryId=category_id, + GroupId=group_id, + GuildId=interaction.guild_id, + MessageId=verification_message.id, + Score=score, + UserName=username, + ) + + session.add(submission) + session.commit() + + def create_embed( # noqa: PLR0913 + self, + interaction: nextcord.Interaction, + group_id: str, + category_id: str, + score: str, + username: str, + session: Session, + ) -> nextcord.Embed: + """ + Creates an embed containing submission data for human verification. + + Args: + interaction (nextcord.Interaction): Discord interaction of the command. + group_id (str): Used to get group name. + category_id (str): Used to get category name. + score (str): Score to set. + username (str): Username to set. + session (Session): Open connection to database. + + Returns: + nextcord.Embed: Embed containing human readable submission data. + """ + embed = nextcord.Embed(title="Highscores Submission", description="Review fields and accept.") + embed.add_field(name="User Name", value=username, inline=False) + + # Get group name from database + stmt = ( + select(CategoryGroup.GroupName) + .where(CategoryGroup.GuildId == interaction.guild_id) + .where(CategoryGroup.GroupId == group_id) + ) + embed.add_field(name="Group", value=session.scalar(stmt), inline=False) + + # Get category name from database + stmt = ( + select(Category.CategoryName).where(Category.GroupId == group_id).where(Category.CategoryId == category_id) + ) + embed.add_field(name="Category", value=session.scalar(stmt), inline=False) + embed.add_field(name="Score", value=score, inline=False) + return embed + + async def verify_score(self, interaction: nextcord.Interaction, score: str, *, is_time_record: bool) -> str | None: + """ + Uses regex to verify that scores are in the correct format. + + Handles responding to the interaction if the format is incorrect. Also standardizes the time record. + + Args: + interaction (nextcord.Interaction): Discord interaction of the command. + score (str): String representation of the score being verified. + is_time_record (bool): Whether the score needs to be verified/formatted as a time record. + + Returns: + str | None: String of corrected score. None if formatted incorrectly. + """ + time_regex = re.search("^([0-9]{1,2}:[0-9]|[0-9])?[0-9](\\.[0-9])?$", score) + int_regex = re.search("^[0-9]+$", score) + if time_regex is None and is_time_record: + await interaction.response.send_message( + "Time record must follow format MM:SS.ms, SS.ms, or SS.", ephemeral=True + ) + return None + if int_regex is None and not is_time_record: + await interaction.response.send_message("Number record must only be number.", ephemeral=True) + return None + if is_time_record: + # standardize time score format. + if re.search("^[1-9]?[0-9]:[0-9]{2}(\\.[0-9])?$", score): + # score is already fine. + pass + elif re.search("^[0-9]{2}(\\.[0-9])?$", score): + # need to add leading colon and 0 + score = "0:" + score + elif re.search("^[0-9](\\.[0-9])?$", score): + # need to add 0 in seconds and leading colon and 0 + score = "0:0" + score + elif re.search("^[0]?[0-9]:[0-9]{2}(\\.[0-9])?$", score): + # if there is a minute 10ths place 0, remove + score = score.lstrip("0") + + return score + + @submit.on_autocomplete("group_id") + async def group_autocomplete(self, interaction: nextcord.Interaction, group_id: str) -> None: + """ + 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)) + + @submit.on_autocomplete("category_id") + async def category_autocomplete(self, interaction: nextcord.Interaction, category_id: str, group_id: str) -> None: + """ + 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) + )