diff --git a/classes/election_cycle.py b/classes/election_cycle.py index 345deb3..9ba4c57 100644 --- a/classes/election_cycle.py +++ b/classes/election_cycle.py @@ -9,7 +9,9 @@ import nextcord from sqlalchemy import select, true from sqlalchemy.orm import Session +from classes.nominate_highcouncil_views import NominateHighCouncilView from classes.nominate_views import NominateView +from classes.voting_highcouncil_views import VoteHighcouncilView from classes.voting_views import VoteView from config import DakinyConfig from database_setup.sql_data_classes import ( @@ -18,7 +20,14 @@ from database_setup.sql_data_classes import ( Votes, engine, ) -from utils import build_votes_str, get_datetime, get_election_cycle_name +from utils import ( + build_highcouncil_votes_str, + build_votes_str, + get_cl_election_name, + get_datetime, + get_dm_election_name, + get_election_cycle_name, +) class ElectionCycle: @@ -283,7 +292,9 @@ class ElectionCycle: reason=f"{member.display_name} has won a nomination and become a Council Elect.", ) - await election_thread.send(message) + await election_thread.send( + message, view=NominateHighCouncilView(self.config, self.guild) + ) async def highcouncil_election_start_action(self) -> None: """ @@ -295,6 +306,19 @@ class ElectionCycle: self.get_election_info().ThreadId ) await election_thread.send(message) + vote_msg = await election_thread.send( + build_highcouncil_votes_str(self.config), + view=VoteHighcouncilView(self.config, self.guild), + ) + + with Session(engine) as session: + stmt = select(ElectionInformation).where( + ElectionInformation.ElectionCycle + == get_election_cycle_name(self.config) + ) + info = session.scalar(stmt) + info.HighCouncilVoteMsgId = vote_msg.id + session.commit() async def term_start_action(self) -> None: """ @@ -310,8 +334,8 @@ class ElectionCycle: self.config.params.clan_council_role_id ) - # role management with Session(engine) as session: + # role management stmt = ( select(CouncilMembers.MemberId) .where( @@ -331,10 +355,34 @@ class ElectionCycle: reason=f"{member.display_name} has been elected to council.", ) + # announce high council members + # ties are undefined behavior. + dm_election = get_dm_election_name(self.config) + cl_election = get_cl_election_name(self.config) + stmt = select(Votes.VotedForId).where(Votes.ElectionCycle == dm_election) + dm_votes = collections.Counter(session.scalars(stmt)) + stmt = select(Votes.VotedForId).where(Votes.ElectionCycle == cl_election) + cl_votes = collections.Counter(session.scalars(stmt)) + + dm_winner = max(dm_votes, key=dm_votes.get) + cl_winner = max(cl_votes, key=cl_votes.get) + election_thread: nextcord.Thread = self.guild.get_thread( self.get_election_info().ThreadId ) + # clear view from vote message + stmt = select(ElectionInformation.HighCouncilVoteMsgId).where( + ElectionInformation.ElectionCycle == get_election_cycle_name(self.config) + ) + vote_msg_id = session.scalar(stmt) + vote_msg: nextcord.Message = await election_thread.fetch_message(vote_msg_id) + await vote_msg.edit(view=None) + + highcouncil_msg: str = f"Congratulations for our high council members!\n Clan Leader: <@{cl_winner}>\n" + highcouncil_msg += f"Discord Manager: <@{dm_winner}>" + await election_thread.send(highcouncil_msg) + await election_thread.send( "This election cycle is now over. Thank you everyone for participating!" ) diff --git a/classes/nominate_highcouncil_views.py b/classes/nominate_highcouncil_views.py new file mode 100644 index 0000000..98e841c --- /dev/null +++ b/classes/nominate_highcouncil_views.py @@ -0,0 +1,266 @@ +""" +Contains various views to be used in the high council nomination process. +""" + +import nextcord +from nextcord.interactions import Interaction +from sqlalchemy import select +from sqlalchemy.orm import Session + +from config import DakinyConfig +from database_setup.sql_data_classes import CouncilMembers, engine +from utils import ( + NOMINEE_SPLITTER, + decorate_cl_nomination, + decorate_dm_nomination, + get_election_cycle_name, +) + + +class NominateDMModal(nextcord.ui.Modal): + """ + Modal for nominating oneself. User writes a speech and then the callback sends the speech into the correct channel. + """ + + def __init__( + self, + config: DakinyConfig, + usr_id: int, + usr_nominated: bool, + speech_content: str, + ): + super().__init__( + "Discord Manager Nomination", + timeout=5 * 60, # 5 minutes + ) + self.usr_id: int = usr_id + self.config: DakinyConfig = config + self.usr_nominated = usr_nominated + + self.speech = nextcord.ui.TextInput( + label="Nomination Speech", + style=nextcord.TextInputStyle.paragraph, + placeholder="Please write your speech here.", + required=True, + max_length=1500, + default_value=speech_content, + ) + self.add_item(self.speech) + + async def callback(self, interaction: Interaction) -> None: + """ + Sends the nomination speech into the voting channel and enters the user into the voting database. + + Args: + interaction (Interaction): Interaction with user. + + """ + + speech = decorate_dm_nomination(interaction.user.id, self.speech.value) + + with Session(engine) as session: + # user already in table + cycle = get_election_cycle_name(self.config) + stmt = ( + select(CouncilMembers) + .where(CouncilMembers.ElectionCycle == cycle) + .where(CouncilMembers.MemberId == self.usr_id) + ) + council_elect = session.scalar(stmt) + + if not self.usr_nominated: + msg = await interaction.channel.send(speech) + + council_elect.DiscordManagerSpeechId = msg.id + session.commit() + else: + msg: nextcord.Message = await interaction.channel.fetch_message( + council_elect.DiscordManagerSpeechId + ) + await msg.edit( + content=decorate_dm_nomination( + interaction.user.id, self.speech.value + ) + ) + + +class NominateCLModal(nextcord.ui.Modal): + """ + Modal for nominating oneself. User writes a speech and then the callback sends the speech into the correct channel. + """ + + def __init__( + self, + config: DakinyConfig, + usr_id: int, + usr_nominated: bool, + speech_content: str, + ): + super().__init__( + "Clan Leader Nomination", + timeout=5 * 60, # 5 minutes + ) + self.usr_id: int = usr_id + self.config: DakinyConfig = config + self.usr_nominated = usr_nominated + + self.speech = nextcord.ui.TextInput( + label="Nomination Speech", + style=nextcord.TextInputStyle.paragraph, + placeholder="Please write your speech here.", + required=True, + max_length=1500, + default_value=speech_content, + ) + self.add_item(self.speech) + + async def callback(self, interaction: Interaction) -> None: + """ + Sends the nomination speech into the voting channel and enters the user into the voting database. + + Args: + interaction (Interaction): Interaction with user. + + """ + + speech = decorate_cl_nomination(interaction.user.id, self.speech.value) + + with Session(engine) as session: + # user already in table + cycle = get_election_cycle_name(self.config) + stmt = ( + select(CouncilMembers) + .where(CouncilMembers.ElectionCycle == cycle) + .where(CouncilMembers.MemberId == self.usr_id) + ) + council_elect = session.scalar(stmt) + + if not self.usr_nominated: + msg = await interaction.channel.send(speech) + + council_elect.ClanLeaderSpeechId = msg.id + session.commit() + else: + msg: nextcord.Message = await interaction.channel.fetch_message( + council_elect.ClanLeaderSpeechId + ) + await msg.edit( + content=decorate_cl_nomination( + interaction.user.id, self.speech.value + ) + ) + + +class NominateHighCouncilView(nextcord.ui.View): + """ + The buttons to press to start your High Council nomination. + """ + + def __init__(self, config: DakinyConfig, guild: nextcord.Guild): + super().__init__(timeout=None) + self.config: DakinyConfig = config + self.guild: nextcord.Guild = guild + + @nextcord.ui.button( + label="Nominate for Discord Manager", + style=nextcord.ButtonStyle.primary, + custom_id="nominate-dm", + ) + async def nominate_dm( + self, button: nextcord.ui.Button, interaction: nextcord.Interaction + ) -> None: + """ + Gathers previous information about the user if they have already nominated themselves, then launches + the nomination modal to get their speech. + + Args: + button (nextcord.ui.Button): Unused. idk why its needed in the decorator + interaction (nextcord.Interaction): Interaction of user who pressed the button + """ + + # enforce council elect only + if ( + self.guild.get_role(self.config.params.clan_council_elect_role_id) + not in interaction.user.roles + ): + await interaction.response.send_message( + f"You must have the <@&{self.config.params.clan_council_elect_role_id}> role to vote.", + ephemeral=True, + ) + return + + cycle = get_election_cycle_name(self.config) + usr_nominated = False + speech_content = "" + with Session(engine) as session: + stmt = ( + select(CouncilMembers) + .where(CouncilMembers.ElectionCycle == cycle) + .where(CouncilMembers.MemberId == interaction.user.id) + ) + obj = session.scalar(stmt) + + # user needs to exist to get here anyways... + if obj.DiscordManagerSpeechId is not None: + usr_nominated = True + msg = await interaction.channel.fetch_message( + obj.DiscordManagerSpeechId + ) + speech_content = msg.content.split(NOMINEE_SPLITTER)[1] + + await interaction.response.send_modal( + NominateDMModal( + self.config, interaction.user.id, usr_nominated, speech_content + ) + ) + + @nextcord.ui.button( + label="Nominate for Clan Leader", + style=nextcord.ButtonStyle.primary, + custom_id="nominate-cl", + ) + async def nominate_cl( + self, button: nextcord.ui.Button, interaction: nextcord.Interaction + ) -> None: + """ + Gathers previous information about the user if they have already nominated themselves, then launches + the nomination modal to get their speech. + + Args: + button (nextcord.ui.Button): Unused. idk why its needed in the decorator + interaction (nextcord.Interaction): Interaction of user who pressed the button + """ + + # enforce council elect only + if ( + self.guild.get_role(self.config.params.clan_council_elect_role_id) + not in interaction.user.roles + ): + await interaction.response.send_message( + f"You must have the <@&{self.config.params.clan_council_elect_role_id}> role to vote.", + ephemeral=True, + ) + return + + cycle = get_election_cycle_name(self.config) + usr_nominated = False + speech_content = "" + with Session(engine) as session: + stmt = ( + select(CouncilMembers) + .where(CouncilMembers.ElectionCycle == cycle) + .where(CouncilMembers.MemberId == interaction.user.id) + ) + obj = session.scalar(stmt) + + # user needs to exist to get here anyways... + if obj.ClanLeaderSpeechId is not None: + usr_nominated = True + msg = await interaction.channel.fetch_message(obj.ClanLeaderSpeechId) + speech_content = msg.content.split(NOMINEE_SPLITTER)[1] + + await interaction.response.send_modal( + NominateCLModal( + self.config, interaction.user.id, usr_nominated, speech_content + ) + ) diff --git a/classes/nominate_views.py b/classes/nominate_views.py index 23dda9e..177ae76 100644 --- a/classes/nominate_views.py +++ b/classes/nominate_views.py @@ -9,9 +9,7 @@ from sqlalchemy.orm import Session from config import DakinyConfig from database_setup.sql_data_classes import CouncilMembers, engine -from utils import get_election_cycle_name - -NOMINEE_SPLITTER = "========================================\n\n" +from utils import NOMINEE_SPLITTER, decorate_nomination, get_election_cycle_name class NominateModal(nextcord.ui.Modal): @@ -142,21 +140,3 @@ class NominateView(nextcord.ui.View): self.config, interaction.user.id, usr_nominated, speech_content ) ) - - -def decorate_nomination(user_id: int, speech: str) -> str: - """ - Decorates a nomination speech so that it sits within multiple speeches better. - - Args: - user_id (int): ID of user sending the speech - speech (str): Speech from the user - - Returns: - str: Decorated speech - """ - msg = f"**Clan Council Nominee: <@{user_id}>**\n" - msg += NOMINEE_SPLITTER - msg += speech - - return msg diff --git a/classes/voting_highcouncil_views.py b/classes/voting_highcouncil_views.py new file mode 100644 index 0000000..44ff2b6 --- /dev/null +++ b/classes/voting_highcouncil_views.py @@ -0,0 +1,253 @@ +""" +Contains various views to be used while voting for high council. Controls the logic when performing the vote. +""" + +from typing import List + +import nextcord +from nextcord import SelectOption +from nextcord.interactions import Interaction +from sqlalchemy import exc, select, update +from sqlalchemy.orm import Session + +from config import DakinyConfig +from database_setup.sql_data_classes import ( + CouncilMembers, + ElectionInformation, + Votes, + engine, +) +from utils import ( + build_highcouncil_votes_str, + get_cl_election_name, + get_dm_election_name, + get_election_cycle_name, +) + + +class VoteSelect(nextcord.ui.StringSelect): + def __init__( + self, options: List["SelectOption"], config: DakinyConfig, election_type: str + ) -> None: + super().__init__( + placeholder="Choose 1", + min_values=1, + max_values=1, + options=options, + ) + self.config = config + self.election_type = election_type + + async def callback(self, interaction: Interaction): + # decide on election type + match self.election_type: + case "cl": + election_name: str = get_cl_election_name(self.config) + case "dm": + election_name: str = get_dm_election_name(self.config) + case _: + # should never happen + raise ValueError('election type was not "cl" or "dm"') + + cycle: str = get_election_cycle_name(self.config) + with Session(engine) as session: + stmt = select(ElectionInformation.HighCouncilVoteMsgId).where( + ElectionInformation.ElectionCycle == cycle + ) + vote_msg_id = session.scalar(stmt) + + if self.values[0] == "Abstain": + voted_for = None + else: + voted_for = int(self.values[0]) + + new_vote = Votes( + ElectionCycle=election_name, + VoterId=interaction.user.id, + VotedForId=voted_for, + ) + + try: + session.add(new_vote) + session.commit() + + except exc.IntegrityError: + session.rollback() + stmt = ( + update(Votes) + .where(Votes.ElectionCycle == election_name) + .where(Votes.VoterId == interaction.user.id) + .values(VotedForId=voted_for) + ) + session.execute(stmt) + session.commit() + + vote_message_str = build_highcouncil_votes_str(self.config) + vote_msg: nextcord.Message = await interaction.channel.fetch_message( + vote_msg_id + ) + await vote_msg.edit(content=vote_message_str) + + if voted_for is not None: + msg = f"You have voted for <@{voted_for}>" + else: + msg = "You have abstained from voting." + + await interaction.edit(content=msg, view=None) + + +class VoteHighcouncilView(nextcord.ui.View): + """ + The button to press to start voting for HighCouncil. + """ + + def __init__(self, config: DakinyConfig, guild: nextcord.Guild): + super().__init__(timeout=None) + self.config = config + self.guild = guild + + @nextcord.ui.button( + label="Vote for Clan Leader", + style=nextcord.ButtonStyle.primary, + custom_id="vote-cl", + ) + async def vote_cl( + self, button: nextcord.ui.Button, interaction: nextcord.Interaction + ) -> None: + """ + Enforces only council elect vote. Gets election cycle, creates voting + options for clan leader (checking if user has voted already), + then sends the voting view. + + Args: + button (nextcord.ui.Button): Unused. idk why its needed in the decorator + interaction (nextcord.Interaction): Interaction of user who pressed the button + """ + + # enforce council elect only + if ( + self.guild.get_role(self.config.params.clan_council_elect_role_id) + not in interaction.user.roles + ): + await interaction.response.send_message( + f"You must have the <@&{self.config.params.clan_council_elect_role_id}> role to vote.", + ephemeral=True, + ) + return + + cycle: str = get_election_cycle_name(self.config) + election: str = get_cl_election_name(self.config) + + with Session(engine) as session: + stmt = ( + select(Votes.VotedForId) + .where(Votes.ElectionCycle == election) + .where(Votes.VoterId == interaction.user.id) + ) + voted_for = session.scalar(stmt) + + stmt = ( + select(CouncilMembers.MemberId) + .where(CouncilMembers.ElectionCycle == cycle) + .filter(CouncilMembers.ClanLeaderSpeechId.is_not(None)) + ) + + options = [ + SelectOption( + label=f"{self.guild.get_member(nominee).display_name} ☑️" + if voted_for == nominee + else f"{self.guild.get_member(nominee).display_name}", + value=str(nominee), + ) + for nominee in session.scalars(stmt) + ] + options.append( + SelectOption( + label="Abstain ☑️" if voted_for is None else "Abstain", + value="Abstain", + ) + ) + + view = nextcord.ui.View() + view.add_item( + VoteSelect(options=options, config=self.config, election_type="cl") + ) + + await interaction.response.send_message( + content="Please vote for a Clan Leader!", + view=view, + ephemeral=True, + ) + + @nextcord.ui.button( + label="Vote for Discord Manager", + style=nextcord.ButtonStyle.primary, + custom_id="vote-dm", + ) + async def vote_dm( + self, button: nextcord.ui.Button, interaction: nextcord.Interaction + ) -> None: + """ + Enforces only council elect vote. Gets election cycle, creates voting + options for discord manager (checking if user has voted already), + then sends the voting view. + + Args: + button (nextcord.ui.Button): Unused. idk why its needed in the decorator + interaction (nextcord.Interaction): Interaction of user who pressed the button + """ + + # enforce council elect only + if ( + self.guild.get_role(self.config.params.clan_council_elect_role_id) + not in interaction.user.roles + ): + await interaction.response.send_message( + f"You must have the <@&{self.config.params.clan_council_elect_role_id}> role to vote.", + ephemeral=True, + ) + return + + cycle: str = get_election_cycle_name(self.config) + election: str = get_dm_election_name(self.config) + + with Session(engine) as session: + stmt = ( + select(Votes.VotedForId) + .where(Votes.ElectionCycle == election) + .where(Votes.VoterId == interaction.user.id) + ) + voted_for = session.scalar(stmt) + + stmt = ( + select(CouncilMembers.MemberId) + .where(CouncilMembers.ElectionCycle == cycle) + .filter(CouncilMembers.DiscordManagerSpeechId.is_not(None)) + ) + + options = [ + SelectOption( + label=f"{self.guild.get_member(nominee).display_name} ☑️" + if voted_for == nominee + else f"{self.guild.get_member(nominee).display_name}", + value=str(nominee), + ) + for nominee in session.scalars(stmt) + ] + options.append( + SelectOption( + label="Abstain ☑️" if voted_for is None else "Abstain", + value="Abstain", + ) + ) + + view = nextcord.ui.View() + view.add_item( + VoteSelect(options=options, config=self.config, election_type="dm") + ) + + await interaction.response.send_message( + content="Please vote for a Discord Manager!", + view=view, + ephemeral=True, + ) diff --git a/cogs/commands/test.py b/cogs/commands/test.py index 82e63a2..42c8148 100644 --- a/cogs/commands/test.py +++ b/cogs/commands/test.py @@ -32,7 +32,7 @@ class TestCog(commands.Cog, name="Test commands."): """ self.bot = bot - @nextcord.slash_command() + @nextcord.slash_command(guild_ids=[1000247361639030804]) async def test(self, interaction: nextcord.Interaction): """ Parent slash command for help. Never called. @@ -90,6 +90,23 @@ class TestCog(commands.Cog, name="Test commands."): ) await cycle.voting_end_action() + @test.subcommand() + @is_me() + async def highcouncil_vote(self, interaction: nextcord.Interaction): + """ + tests the voting start action + + Args: + interaction (nextcord.Interaction): _description_ + """ + config: DakinyConfig = self.bot.config + cycle = ElectionCycle( + get_current_cycle(config), + config, + self.bot.get_guild(config.params.guild_id), + ) + await cycle.highcouncil_election_start_action() + @test.subcommand() @is_me() async def election_reminder(self, interaction: nextcord.Interaction): diff --git a/dakiny_bot.py b/dakiny_bot.py index ab9bcf6..b1679fb 100644 --- a/dakiny_bot.py +++ b/dakiny_bot.py @@ -8,7 +8,9 @@ from hydra.core.config_store import ConfigStore from nextcord.ext import commands import database_setup.create_database as create_database +from classes.nominate_highcouncil_views import NominateHighCouncilView from classes.nominate_views import NominateView +from classes.voting_highcouncil_views import VoteHighcouncilView from classes.voting_views import VoteView from config import DakinyConfig @@ -54,7 +56,9 @@ def main(cfg: DakinyConfig): assert bot.user is not None create_database # pylint: disable=pointless-statement bot.add_view(NominateView(cfg, bot.get_guild(cfg.params.guild_id))) + bot.add_view(NominateHighCouncilView(cfg, bot.get_guild(cfg.params.guild_id))) bot.add_view(VoteView(cfg, bot.get_guild(cfg.params.guild_id))) + bot.add_view(VoteHighcouncilView(cfg, bot.get_guild(cfg.params.guild_id))) print(f"{bot.user.name} has connected to Discord!") # Run Discord bot diff --git a/database_setup/database.sql b/database_setup/database.sql index 64f77a9..e976e69 100644 --- a/database_setup/database.sql +++ b/database_setup/database.sql @@ -2,17 +2,19 @@ CREATE TABLE ElectionInformation( ElectionCycle varchar(30) not null unique, ThreadId bigint unsigned not null, CouncilVoteMsgId bigint unsigned, + HighCouncilVoteMsgId bigint unsigned, PRIMARY KEY (ElectionCycle) ); CREATE TABLE CouncilMembers( ElectionCycle varchar(30) not null, MemberId bigint unsigned not null, - SpeechMsgId bigint unsigned not null, + SpeechMsgId bigint unsigned not null, Elected boolean DEFAULT false, - HighCouncilElect boolean DEFAULT false, DiscordManager boolean DEFAULT false, ClanLeader boolean DEFAULT false, + DiscordManagerSpeechId bigint unsigned, + ClanLeaderSpeechId bigint unsigned, FOREIGN KEY (ElectionCycle) REFERENCES ElectionInformation(ElectionCycle), PRIMARY KEY (ElectionCycle, MemberId) ); diff --git a/utils.py b/utils.py index 8f2774b..7fb2c93 100644 --- a/utils.py +++ b/utils.py @@ -10,6 +10,8 @@ from sqlalchemy.orm import Session from config import DakinyConfig from database_setup.sql_data_classes import CouncilMembers, Votes, engine +NOMINEE_SPLITTER = "========================================\n\n" + def get_datetime(day: str) -> datetime: """ @@ -36,13 +38,46 @@ def get_election_cycle_name(config: DakinyConfig) -> str: """ Builds the unique name of the current election cycle + Args: + config (DakinyConfig): the config used to know the election cycle. + Returns: str: the name of the election cycle """ cycle = get_current_cycle(config) current_cycle = config.election_cycles[cycle] - return f"vote-{get_datetime(current_cycle.election_start).year}-{current_cycle.name}" + return ( + f"vote-{get_datetime(current_cycle.election_start).year}-{current_cycle.name}" + ) + + +def get_dm_election_name(config: DakinyConfig) -> str: + """ + creates the name of the discord manager election + + Args: + config (DakinyConfig): Config sent to get_election_cycle_name + + Returns: + str: the name of the election + """ + + return f"{get_election_cycle_name(config)}-dm" + + +def get_cl_election_name(config: DakinyConfig) -> str: + """ + creates the name of the clan leader election + + Args: + config (DakinyConfig): Config sent to get_election_cycle_name + + Returns: + str: the name of the election + """ + + return f"{get_election_cycle_name(config)}-cl" def get_current_cycle(config: DakinyConfig) -> int: @@ -82,22 +117,148 @@ def build_votes_str(config: DakinyConfig) -> str: votes: dict[int, int] = {} with Session(engine) as session: - stmt = select(CouncilMembers).where(CouncilMembers.ElectionCycle == get_election_cycle_name(config)) + stmt = select(CouncilMembers).where( + CouncilMembers.ElectionCycle == get_election_cycle_name(config) + ) for nominee in session.scalars(stmt): votes[nominee.MemberId] = 0 - stmt = select(Votes).where(Votes.ElectionCycle == get_election_cycle_name(config)) + stmt = select(Votes).where( + Votes.ElectionCycle == get_election_cycle_name(config) + ) for vote in session.scalars(stmt): if vote.VotedForId is not None: votes[vote.VotedForId] += 1 votes_str: str = "====================" - for user_id, num_votes in votes.items(): - votes_str += f"\n<@{user_id}>: " - - for num in range(num_votes): - votes_str += "☑️" + votes_str += user_vote_str(votes) return votes_str + + +def build_highcouncil_votes_str(config: DakinyConfig) -> str: + """ + Creates a string that contains all high council nominees and their votes to be sent as a discord message. + + Args: + config (DakinyConfig): Daking config object + + Returns: + str: String that contains all nominees and their votes. + """ + + cl_votes: dict[int, int] = {} + dm_votes: dict[int, int] = {} + + with Session(engine) as session: + stmt = ( + select(CouncilMembers) + .where(CouncilMembers.ElectionCycle == get_election_cycle_name(config)) + .filter(CouncilMembers.ClanLeaderSpeechId.is_not(None)) + ) + + for nominee in session.scalars(stmt): + cl_votes[nominee.MemberId] = 0 + + stmt = select(Votes).where(Votes.ElectionCycle == get_cl_election_name(config)) + + for vote in session.scalars(stmt): + if vote.VotedForId is not None: + cl_votes[vote.VotedForId] += 1 + + stmt = ( + select(CouncilMembers) + .where(CouncilMembers.ElectionCycle == get_election_cycle_name(config)) + .filter(CouncilMembers.DiscordManagerSpeechId.is_not(None)) + ) + + for nominee in session.scalars(stmt): + dm_votes[nominee.MemberId] = 0 + + stmt = select(Votes).where(Votes.ElectionCycle == get_dm_election_name(config)) + + for vote in session.scalars(stmt): + if vote.VotedForId is not None: + dm_votes[vote.VotedForId] += 1 + + # build the votes string + votes_str: str = "Clan Leader Votes\n====================" + votes_str += user_vote_str(cl_votes) + + votes_str += "\n\nDiscord Manager Votes\n====================" + votes_str += user_vote_str(dm_votes) + + return votes_str + + +def user_vote_str(votes: dict[int, int]) -> str: + """ + Creates the string representing how many votes each user has. + + Args: + votes (dict[int, int]): Dict of all users and how many votes they have. + + Returns: + str: The string showing names and number of votes. + """ + ret_str: str = "" + + for user_id, num_votes in votes.items(): + ret_str += f"\n<@{user_id}>: {'☑️' * num_votes}" + return ret_str + + +def decorate_nomination(user_id: int, speech: str) -> str: + """ + Decorates a nomination speech so that it sits within multiple speeches better. + + Args: + user_id (int): ID of user sending the speech + speech (str): Speech from the user + + Returns: + str: Decorated speech + """ + msg = f"**Clan Council Nominee: <@{user_id}>**\n" + msg += NOMINEE_SPLITTER + msg += speech + + return msg + + +def decorate_cl_nomination(user_id: int, speech: str) -> str: + """ + Decorates a nomination speech so that it sits within multiple speeches better. + + Args: + user_id (int): ID of user sending the speech + speech (str): Speech from the user + + Returns: + str: Decorated speech + """ + msg = f"**Clan Leader Nominee: <@{user_id}>**\n" + msg += NOMINEE_SPLITTER + msg += speech + + return msg + + +def decorate_dm_nomination(user_id: int, speech: str) -> str: + """ + Decorates a nomination speech so that it sits within multiple speeches better. + + Args: + user_id (int): ID of user sending the speech + speech (str): Speech from the user + + Returns: + str: Decorated speech + """ + msg = f"**Discord Manager Nominee: <@{user_id}>**\n" + msg += NOMINEE_SPLITTER + msg += speech + + return msg