diff --git a/bot_messages/vote_start.txt b/bot_messages/vote_start.txt index f67865e..afd1b0d 100644 --- a/bot_messages/vote_start.txt +++ b/bot_messages/vote_start.txt @@ -1,7 +1,7 @@ ==================== <@&{clan_member_id}>, it's time to vote for clan council. -React with <{voting_emote_id}> to vote. +Press the Vote button to vote! Voting ends next {voting_end_day}, {voting_end}. Results will then be announced. diff --git a/classes/election_cycle.py b/classes/election_cycle.py index 274f856..a7c02ac 100644 --- a/classes/election_cycle.py +++ b/classes/election_cycle.py @@ -2,15 +2,18 @@ Contains the election cycle class """ +import collections from datetime import datetime import nextcord from sqlalchemy import select from sqlalchemy.orm import Session +from classes.nominate_views import NominateView +from classes.voting_views import VoteView from config import DakinyConfig -from database_setup.sql_data_classes import ElectionInformation, engine -from utils import get_datetime +from database_setup.sql_data_classes import CouncilMembers, ElectionInformation, Votes, engine +from utils import build_votes_str, get_datetime, get_election_cycle_name class ElectionCycle: @@ -31,15 +34,15 @@ class ElectionCycle: self.config: DakinyConfig = config self.cycle = cycle - self.elecion_cycle = self.config.election_cycles[self.cycle] - self.election_start: datetime = get_datetime(self.elecion_cycle.election_start) - self.term_start: datetime = get_datetime(self.elecion_cycle.term_start) - self.term_end: datetime = get_datetime(self.elecion_cycle.term_end) - self.voting_start: datetime = get_datetime(self.elecion_cycle.voting_start) - self.voting_end: datetime = get_datetime(self.elecion_cycle.voting_end) - self.highcouncil_election_announce: datetime = get_datetime(self.elecion_cycle.highcouncil_election_announce) - self.highcouncil_election_start: datetime = get_datetime(self.elecion_cycle.highcouncil_election_start) - self.highcouncil_election_end: datetime = get_datetime(self.elecion_cycle.highcouncil_election_end) + self.election_cycle = self.config.election_cycles[self.cycle] + self.election_start: datetime = get_datetime(self.election_cycle.election_start) + self.term_start: datetime = get_datetime(self.election_cycle.term_start) + self.term_end: datetime = get_datetime(self.election_cycle.term_end) + self.voting_start: datetime = get_datetime(self.election_cycle.voting_start) + self.voting_end: datetime = get_datetime(self.election_cycle.voting_end) + self.highcouncil_election_announce: datetime = get_datetime(self.election_cycle.highcouncil_election_announce) + self.highcouncil_election_start: datetime = get_datetime(self.election_cycle.highcouncil_election_start) + self.highcouncil_election_end: datetime = get_datetime(self.election_cycle.highcouncil_election_end) self.guild: nextcord.Guild = guild self.election_over: bool = False @@ -122,7 +125,6 @@ class ElectionCycle: return message.format( clan_member_id=self.config.params.clan_member_role_id, - voting_emote_id=self.config.params.voting_emote_id, voting_end=vote_end.strftime("%B %d"), voting_end_day=vote_end.strftime("%A"), ) @@ -151,12 +153,12 @@ class ElectionCycle: """ # create new channel for election category: nextcord.CategoryChannel = self.guild.get_channel(self.config.params.active_category_id) - quarter_name: str = self.get_election_cycle_name() + quarter_name: str = get_election_cycle_name(self.config) # create new entry into database. election_channel: nextcord.TextChannel = await category.create_text_channel(quarter_name) clan_member_role: nextcord.Role = self.guild.get_role(self.config.params.clan_member_role_id) - await election_channel.set_permissions(clan_member_role, send_messages=True, add_reactions=True) + await election_channel.set_permissions(clan_member_role, add_reactions=True) with Session(engine) as session: new_cycle = ElectionInformation(ElectionCycle=quarter_name, ElectionChannel=election_channel.id) session.add(new_cycle) @@ -166,29 +168,74 @@ class ElectionCycle: # election start message is returned as an array of string because it is too long. send each string # individually. messages = self.build_election_announcement() - for message in messages: - await election_channel.send(message) + for index, message in enumerate(messages): + # if last item, add the nominate button + if index + 1 == len(messages): + await election_channel.send(message, view=NominateView(self.config)) + else: + await election_channel.send(message) async def voting_start_action(self) -> None: """ Sends message informing members that voting has started. Removes write permission from the general clan member role. - Adds the voting emote to all messages not sent by the vote. (This is temporary until voting is managed.) """ message = self.build_vote_start() clan_member_role: nextcord.Role = self.guild.get_role(self.config.params.clan_member_role_id) election_channel: nextcord.TextChannel = self.guild.get_channel(self.get_election_info().ElectionChannel) await election_channel.send(message) + vote_msg = await election_channel.send(build_votes_str(self.config), view=VoteView(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.CouncilVoteMsgId = vote_msg.id + session.commit() + await election_channel.set_permissions(clan_member_role, send_messages=False) async def voting_end_action(self) -> None: """ Performs required actions for when voting time has ended. """ + message = self.build_highcouncil_election_announce() council_elect_role: nextcord.Role = self.guild.get_role(self.config.params.clan_council_elect_role_id) election_channel: nextcord.TextChannel = self.guild.get_channel(self.get_election_info().ElectionChannel) clan_member_role: nextcord.Role = self.guild.get_role(self.config.params.clan_member_role_id) + + # remove vote button from vote message + election_name = get_election_cycle_name(self.config) + with Session(engine) as session: + stmt = select(ElectionInformation.CouncilVoteMsgId).where( + ElectionInformation.ElectionCycle == election_name + ) + vote_msg_id = session.scalar(stmt) + vote_msg: nextcord.Message = await election_channel.fetch_message(vote_msg_id) + await vote_msg.edit(view=None) + + # add the highcouncil role to everyone with 3 or more votes. set elected to true in database + with Session(engine) as session: + stmt = select(Votes.VotedForId).where(Votes.ElectionCycle == election_name) + vote_count = collections.Counter(session.scalars(stmt)) + + for user_id, votes in vote_count.items(): + if votes >= 3: + stmt = ( + select(CouncilMembers) + .where(CouncilMembers.ElectionCycle == election_name) + .where(CouncilMembers.MemberId == user_id) + ) + council_member = session.scalar(stmt) + council_member.Elected = True + session.commit() + + user: nextcord.Member = self.guild.get_member(user_id) + + await user.add_roles(council_elect_role) + await election_channel.send(message) await election_channel.set_permissions(council_elect_role, send_messages=True, add_reactions=True) await election_channel.set_permissions(clan_member_role, add_reactions=False) @@ -213,16 +260,6 @@ class ElectionCycle: await election_channel.move(beginning=True, category=archive) - def get_election_cycle_name(self) -> str: - """ - Builds the unique name of the current election cycle - - Returns: - str: the name of the election cycle - """ - - return f"vote-{self.election_start.year}-{self.elecion_cycle.name}" - def get_election_info(self) -> ElectionInformation: """ Gets information for current election. @@ -233,6 +270,6 @@ class ElectionCycle: with Session(engine) as session: stmt = select(ElectionInformation).where( - ElectionInformation.ElectionCycle == self.get_election_cycle_name() + ElectionInformation.ElectionCycle == get_election_cycle_name(self.config) ) return session.scalar(stmt) diff --git a/classes/nominate_views.py b/classes/nominate_views.py new file mode 100644 index 0000000..b66075f --- /dev/null +++ b/classes/nominate_views.py @@ -0,0 +1,133 @@ +""" +Contains various views to be used in the 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 get_election_cycle_name + +NOMINEE_SPLITTER = "========================================================\n\n" + + +class NominateModal(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__( + "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_nomination(interaction.user.id, self.speech.value) + + with Session(engine) as session: + if not self.usr_nominated: + msg = await interaction.channel.send(speech) + + council_member = CouncilMembers( + ElectionCycle=get_election_cycle_name(self.config), + MemberId=interaction.user.id, + SpeechMsgId=msg.id, + ) + session.add(council_member) + session.commit() + else: + # user already nominated, just need to update the election speech + cycle = get_election_cycle_name(self.config) + stmt = ( + select(CouncilMembers) + .where(CouncilMembers.ElectionCycle == cycle) + .where(CouncilMembers.MemberId == self.usr_id) + ) + obj = session.scalar(stmt) + obj.SpeechMsgContents = self.speech.value + + msg: nextcord.Message = await interaction.channel.fetch_message(obj.SpeechMsgId) + await msg.edit(content=decorate_nomination(interaction.user.id, self.speech.value)) + + +class NominateView(nextcord.ui.View): + """ + The button to press to start your nomination. + """ + + def __init__(self, config): + super().__init__(timeout=None) + self.config: DakinyConfig = config + + @nextcord.ui.button(label="Nominate Yourself", style=nextcord.ButtonStyle.primary, custom_id="nominate") + async def nominate(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 + """ + + 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) + if obj is not None: + usr_nominated = True + msg = await interaction.channel.fetch_message(obj.SpeechMsgId) + speech_content = msg.content.split(NOMINEE_SPLITTER)[1] + + await interaction.response.send_modal( + NominateModal(self.config, interaction.user.id, usr_nominated, speech_content) + ) + + +def decorate_nomination(user_id, speech) -> str: + """ + Decorates a nomination speech so that it sits within multiple speeches better. + + Args: + user_id (_type_): ID of user sending the speech + speech (_type_): 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_views.py b/classes/voting_views.py new file mode 100644 index 0000000..6444554 --- /dev/null +++ b/classes/voting_views.py @@ -0,0 +1,127 @@ +""" +Contains various views to be used while voting. 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 +from sqlalchemy.orm import Session + +from config import DakinyConfig +from database_setup.sql_data_classes import CouncilMembers, ElectionInformation, Votes, engine +from utils import build_votes_str, get_election_cycle_name + + +class VoteSelect(nextcord.ui.StringSelect): + def __init__(self, options: List["SelectOption"], config: DakinyConfig) -> None: + super().__init__( + placeholder="Choose 1", + min_values=1, + max_values=1, + options=options, + ) + self.config = config + + async def callback(self, interaction: Interaction): + election_name = get_election_cycle_name(self.config) + with Session(engine) as session: + stmt = select(ElectionInformation.CouncilVoteMsgId).where( + ElectionInformation.ElectionCycle == election_name + ) + 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 = ( + select(Votes) + .where(Votes.ElectionCycle == election_name) + .where(Votes.VoterId == interaction.user.id) + ) + vote = session.scalar(stmt) + vote.VotedForId = voted_for + session.commit() + + vote_message_str = build_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 VoteView(nextcord.ui.View): + """ + The button to press to start your nomination. + """ + + def __init__(self, config: DakinyConfig, guild: nextcord.Guild): + super().__init__(timeout=None) + self.config = config + self.guild = guild + + @nextcord.ui.button(label="Vote", style=nextcord.ButtonStyle.primary, custom_id="vote") + async def vote(self, button: nextcord.ui.Button, interaction: nextcord.Interaction) -> None: + """ + TODO + + Args: + button (nextcord.ui.Button): Unused. idk why its needed in the decorator + interaction (nextcord.Interaction): Interaction of user who pressed the button + """ + + # enforce members only + if self.guild.get_role(self.config.params.clan_member_role_id) not in interaction.user.roles: + await interaction.response.send_message( + f"You must have the <@&{self.config.params.clan_member_role_id}> role to vote.", ephemeral=True + ) + return + + election: str = get_election_cycle_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 == election) + + options = [ + SelectOption( + label=f"{self.guild.get_member(nominee).nick} ☑️" + if voted_for == nominee + else f"{self.guild.get_member(nominee).nick}", + 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)) + + await interaction.response.send_message( + content="Please vote for a clan member!", + view=view, + ephemeral=True, + ) diff --git a/cogs/commands/test.py b/cogs/commands/test.py index 948a331..6b3a863 100644 --- a/cogs/commands/test.py +++ b/cogs/commands/test.py @@ -3,9 +3,11 @@ Commands for testing. """ import nextcord -from nextcord.ext import commands +from nextcord.ext import application_checks, commands from classes.election_cycle import ElectionCycle +from config import DakinyConfig +from utils import get_current_cycle class TestCog(commands.Cog, name="Test commands."): @@ -19,7 +21,14 @@ class TestCog(commands.Cog, name="Test commands."): """ self.bot = bot + def is_me(): + def predicate(interaction: nextcord.Interaction): + return interaction.user.id == 206602713511886849 + + return application_checks.check(predicate) + @nextcord.slash_command() + @is_me() async def test(self, interaction: nextcord.Interaction): """ Parent slash command for help. Never called. @@ -51,7 +60,8 @@ class TestCog(commands.Cog, name="Test commands."): interaction (nextcord.Interaction): _description_ """ - cycle = ElectionCycle(0, self.bot) + config: DakinyConfig = self.bot.config + cycle = ElectionCycle(get_current_cycle(config), config, self.bot.get_guild(config.params.guild_id)) await cycle.election_start_action() @test.subcommand() @@ -62,5 +72,18 @@ class TestCog(commands.Cog, name="Test commands."): Args: interaction (nextcord.Interaction): _description_ """ - cycle = ElectionCycle(0, self.bot) + config: DakinyConfig = self.bot.config + cycle = ElectionCycle(get_current_cycle(config), config, self.bot.get_guild(config.params.guild_id)) await cycle.voting_start_action() + + @test.subcommand() + async def voting_end(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.voting_end_action() diff --git a/cogs/loops/daily_loop.py b/cogs/loops/daily_loop.py index 30ed4f7..ffb8caf 100644 --- a/cogs/loops/daily_loop.py +++ b/cogs/loops/daily_loop.py @@ -15,7 +15,7 @@ from classes.election_cycle import ElectionCycle from config import DakinyConfig from dakiny_bot import birthday_list from database_setup.sql_data_classes import UserBirthday, engine -from utils import get_datetime +from utils import get_current_cycle class DailyLoop(commands.Cog, name="Daily Loop"): @@ -40,7 +40,7 @@ class DailyLoop(commands.Cog, name="Daily Loop"): """ if self.current_election is None: - self.current_election = self.get_current_cycle() + self.current_election = self.get_election_cycle() if self.current_election is not None: await self.current_election.check_date() @@ -65,9 +65,9 @@ class DailyLoop(commands.Cog, name="Daily Loop"): await self.bot.wait_until_ready() # Bot needs to know guilds to get the current cycle. - self.current_election = self.get_current_cycle() + self.current_election = self.get_election_cycle() - def get_current_cycle(self) -> ElectionCycle: + def get_election_cycle(self) -> ElectionCycle: """ Creates an object for the current election cycle if one is active. An election is considered active between the election start and the term start. @@ -76,18 +76,10 @@ class DailyLoop(commands.Cog, name="Daily Loop"): ElectionCycle: Current election cycle, None if there is no election happening. """ - today = datetime.today() config: DakinyConfig = self.bot.config - current_election = None + cycle = get_current_cycle(config) - for cycle, election in enumerate(config.election_cycles): - if ( - today.date() >= get_datetime(election.election_start).date() - and today.date() <= get_datetime(election.term_start).date() - ): - current_election = ElectionCycle(cycle, config, self.bot.get_guild(config.params.guild_id)) - - return current_election + return ElectionCycle(cycle, config, self.bot.get_guild(config.params.guild_id)) def update_birthdays(self) -> None: """ diff --git a/config.py b/config.py index 084c72e..1dfc500 100644 --- a/config.py +++ b/config.py @@ -22,7 +22,6 @@ class Params: general_channel_id: int guild_id: int high_council_role_id: int - voting_emote_id: str @dataclass diff --git a/dakiny_bot.py b/dakiny_bot.py index 076cd15..0c0cc53 100644 --- a/dakiny_bot.py +++ b/dakiny_bot.py @@ -8,6 +8,8 @@ from hydra.core.config_store import ConfigStore from nextcord.ext import commands import database_setup.create_database as create_database +from classes.nominate_views import NominateView +from classes.voting_views import VoteView from config import DakinyConfig cs = ConfigStore.instance() @@ -49,6 +51,8 @@ def main(cfg: DakinyConfig): """When Discord is connected""" assert bot.user is not None create_database # pylint: disable=pointless-statement + bot.add_view(NominateView(cfg)) + bot.add_view(VoteView(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 9271b25..299e621 100644 --- a/database_setup/database.sql +++ b/database_setup/database.sql @@ -1,12 +1,14 @@ CREATE TABLE ElectionInformation( ElectionCycle varchar(30) not null unique, ElectionChannel bigint unsigned not null, + CouncilVoteMsgId bigint unsigned, PRIMARY KEY (ElectionCycle) ); CREATE TABLE CouncilMembers( - ElectionCycle varchar(30) not null unique, + ElectionCycle varchar(30) not null, MemberId bigint unsigned not null, + SpeechMsgId bigint unsigned not null, Elected boolean DEFAULT false, HighCouncilElect boolean DEFAULT false, DiscordManager boolean DEFAULT false, @@ -16,9 +18,9 @@ CREATE TABLE CouncilMembers( ); CREATE TABLE Votes( - ElectionCycle varchar(30) not null unique, + ElectionCycle varchar(30) not null, VoterId bigint unsigned not null, - VotedForId bigint unsigned not null, + VotedForId bigint unsigned, FOREIGN KEY (ElectionCycle) REFERENCES ElectionInformation(ElectionCycle), PRIMARY KEY (ElectionCycle, VoterId) ); diff --git a/utils.py b/utils.py index b0f0d92..8f2774b 100644 --- a/utils.py +++ b/utils.py @@ -4,6 +4,12 @@ File contains util functions. from datetime import datetime +from sqlalchemy import select +from sqlalchemy.orm import Session + +from config import DakinyConfig +from database_setup.sql_data_classes import CouncilMembers, Votes, engine + def get_datetime(day: str) -> datetime: """ @@ -24,3 +30,74 @@ def get_datetime(day: str) -> datetime: year += 1 return datetime(year, month, day) + + +def get_election_cycle_name(config: DakinyConfig) -> str: + """ + Builds the unique name of the current 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}" + + +def get_current_cycle(config: DakinyConfig) -> int: + """ + Returns index for active election. An election is considered active between the election start and the term start. + + Args: + config (DakinyConfig): Config object to use + + Returns: + int: cycle index for active election. -1 for no active cycle + """ + + today = datetime.today() + + for cycle, election in enumerate(config.election_cycles): + if ( + today.date() >= get_datetime(election.election_start).date() + and today.date() <= get_datetime(election.term_start).date() + ): + return cycle + + return -1 + + +def build_votes_str(config: DakinyConfig) -> str: + """ + Creates a string that contains all 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. + """ + + votes: dict[int, int] = {} + + with Session(engine) as session: + 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)) + + 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 += "☑️" + + return votes_str