diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ad354d4..78df2100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ administrators - Fix an issue where multiple emails can be passed to bypass domain-specific verification ### Insights - Added new commands `insights` and `servers` under the insights cog +### Voting +- Add backend API ## [0.6.0] - 01-01-2023 - Upgraded to discord.py 2.1.0 diff --git a/koala/cogs/verification/api.py b/koala/cogs/verification/api.py index 2082dab0..25a929aa 100644 --- a/koala/cogs/verification/api.py +++ b/koala/cogs/verification/api.py @@ -85,4 +85,4 @@ def setup(bot: Bot): endpoint = VerifyEndpoint(bot) endpoint.register(sub_app) getattr(bot, "koala_web_app").add_subapp('/{}'.format(VERIFY_ENDPOINT), sub_app) - logger.info("Base API is ready.") + logger.info("Verify API is ready.") diff --git a/koala/cogs/voting/__init__.py b/koala/cogs/voting/__init__.py index 1c7ac185..c179c63f 100644 --- a/koala/cogs/voting/__init__.py +++ b/koala/cogs/voting/__init__.py @@ -1,2 +1,7 @@ -from . import utils, db, log, models -from .cog import Voting, setup +from . import api +from . import cog +from .cog import Voting + +async def setup(bot): + await cog.setup(bot) + api.setup(bot) \ No newline at end of file diff --git a/koala/cogs/voting/api.py b/koala/cogs/voting/api.py new file mode 100644 index 00000000..e452a22b --- /dev/null +++ b/koala/cogs/voting/api.py @@ -0,0 +1,179 @@ +# Futures +# Built-in/Generic Imports +# Libs +from http.client import BAD_REQUEST, CREATED, OK +from typing import List + +import discord +from aiohttp import web +from discord.ext.commands import Bot +from koala.cogs.voting.db import VoteManager +from koala.cogs.voting.option import Option + +from koala.rest.api import parse_request, build_response +# Own modules +from . import core +from .log import logger + +# Constants +VOTING_ENDPOINT = 'vote' +CONFIG_ENDPOINT = 'config' +RESULTS_ENDOPINT = 'results' + +class VotingEndpoint: + """ + The API endpoints for Voting + """ + def __init__(self, bot): + self._bot = bot + + def register(self, app): + """ + Register the routes for the given application + todo: review aiohttp 'views' and see if they are a better idea + :param app: The aiohttp.web.Application (likely of the sub app) + :return: app + """ + app.add_routes([web.post('/{endpoint}'.format(endpoint=CONFIG_ENDPOINT), self.post_new_vote), + web.get('/{endpoint}'.format(endpoint=CONFIG_ENDPOINT), self.get_current_votes), + web.post('/{endpoint}'.format(endpoint=RESULTS_ENDOPINT), self.post_close_results), + web.get('/{endpoint}'.format(endpoint=RESULTS_ENDOPINT), self.get_results)]) + return app + + + @parse_request(raw_response=True) + async def post_new_vote(self, title, author_id, guild_id, options: List[dict], + roles=None, chair_id=None, channel_id=None, end_time=None): + """ + Create a new vote. + :param title: The name of the vote + :param author_id: The author id of the vote + :param guild_id: The guild id of the vote + :param options: The options for the votes + :param roles: The target roles for the votes + :param chair_id: The chair id of the vote + :param channel_id: Channel id of the vote + :param end_time: The end time of the vote + :return: + """ + try: + core.start_vote(self._bot, title, author_id, guild_id) + + if channel_id is not None: + await core.set_channel(self._bot, author_id, channel_id) + + for item in options: + core.add_option(author_id, item["header"], item["body"]) + + if roles is not None: + for item in roles: + core.set_roles(self._bot, author_id, guild_id, item, "add") + + if chair_id is not None: + await core.set_chair(self._bot, author_id, chair_id) + + if end_time is not None: + core.set_end_time(author_id, end_time) + + await core.send_vote(self._bot, author_id, guild_id) + + except Exception as e: + logger.error(e) + raise web.HTTPUnprocessableEntity() + + return build_response(CREATED, {'message': f'Vote {title} created'}) + + + @parse_request(raw_response=True) + async def get_current_votes(self, author_id, guild_id): + """ + Gets list of open votes. + :param author_id: The author id of the vote + :param guild: The guild id of the vote + :return: + """ + try: + embed = core.current_votes(author_id, guild_id) + + if embed.description: + body = embed.description + else: + body = embed.fields[0].value + + return build_response(OK, {'embed_title': f'{embed.title}', 'embed_body': f'{body}'}) + + except Exception as e: + logger.error(e) + raise web.HTTPUnprocessableEntity() + + + @parse_request(raw_response=True) + async def post_close_results(self, author_id, title): + """ + Gets results and closes the vote. + :param author_id: The author id of the vote + :param title: The title of the vote + :return: + """ + try: + embed = await core.close(self._bot, author_id, title) + if embed.fields[0].name == "No votes yet!": + body = embed.fields[0].value + else: + body = "" + for item in embed.fields: + body += item.name + ", " + item.value + "\n" + + return build_response(OK, {'embed_title': f'{embed.title}', + 'embed_body': f'{body}'}) + + except Exception as e: + logger.error(e) + raise web.HTTPUnprocessableEntity() + + + + @parse_request(raw_response=True) + async def get_results(self, author_id, title): + """ + Gets results, but does not close the vote. + :param author_id: The author id of the vote + :param title: The title of the vote + :return: + """ + try: + message = await core.results(self._bot, author_id, title) + if type(message) is discord.Embed: + + if message.fields[0].name == "No votes yet!": + body = message.fields[0].value + + else: + body = "" + for item in message.fields: + body += item.name + ", " + item.value + "\n" + + return build_response(OK, {'embed_title': f'{message.title}', + 'embed_body': f'{body}'}) + + else: + return build_response(BAD_REQUEST, {'message': message}) + + except ValueError as e: + raise web.HTTPUnprocessableEntity(reason="{}".format(e)) + + except Exception as e: + logger.error(e) + raise web.HTTPUnprocessableEntity() + + +def setup(bot: Bot): + """ + Load this cog to the KoalaBot. + :param bot: the bot client for KoalaBot + """ + sub_app = web.Application() + endpoint = VotingEndpoint(bot) + endpoint.register(sub_app) + getattr(bot, "koala_web_app").add_subapp('/{extension}'.format(extension=VOTING_ENDPOINT), sub_app) + logger.info("Voting API is ready.") diff --git a/koala/cogs/voting/cog.py b/koala/cogs/voting/cog.py index 3056d723..889237ea 100644 --- a/koala/cogs/voting/cog.py +++ b/koala/cogs/voting/cog.py @@ -5,22 +5,18 @@ Commented using reStructuredText (reST) """ # Built-in/Generic Imports -import time # Libs import discord -import parsedatetime.parsedatetime from discord.ext import commands, tasks -from sqlalchemy import select, delete, update # Own modules import koalabot -from koala.db import session_manager, insert_extension -from .db import VoteManager, get_results, create_embed, add_reactions +from koala.db import insert_extension +from . import core +from .db import add_reactions from .log import logger -from .models import Votes from .option import Option -from .utils import make_result_embed # Constants @@ -34,20 +30,18 @@ def currently_configuring(): :return: True if the user has an active vote, false if not """ async def predicate(ctx): - cog = ctx.command.cog if koalabot.is_dm_channel(ctx): return False - return ctx.author.id in cog.vote_manager.configuring_votes.keys() and cog.vote_manager.configuring_votes[ctx.author.id].guild == ctx.guild.id + return ctx.author.id in core.vm.configuring_votes.keys() and core.vm.configuring_votes[ctx.author.id].guild == ctx.guild.id return commands.check(predicate) def has_current_votes(): async def predicate(ctx): - cog = ctx.command.cog if koalabot.is_dm_channel(ctx): return False - return ctx.author.id in map(lambda x: x[0], cog.vote_manager.vote_lookup.keys()) + return ctx.author.id in map(lambda x: x[0], core.vm.vote_lookup.keys()) return commands.check(predicate) @@ -76,8 +70,8 @@ def __init__(self, bot): """ self.bot = bot insert_extension("Vote", 0, True, True) - self.vote_manager = VoteManager() - self.vote_manager.load_from_db() + # self.vote_manager = VoteManager() + # self.vote_manager.load_from_db() self.running = False @commands.Cog.listener() @@ -92,57 +86,21 @@ async def cog_unload(self): @tasks.loop(seconds=60.0) async def vote_end_loop(self): - try: - with session_manager() as session: - now = time.time() - votes = session.execute(select(Votes.vote_id, Votes.author_id, Votes.guild_id, Votes.title, Votes.end_time) - .where(Votes.end_time < now)).all() - for v_id, a_id, g_id, title, end_time in votes: - if v_id in self.vote_manager.sent_votes.keys(): - vote = self.vote_manager.get_vote_from_id(v_id) - results = await get_results(self.bot, vote) - embed = await make_result_embed(vote, results) - try: - if vote.chair: - try: - chair = await self.bot.fetch_user(vote.chair) - await chair.send(f"Your vote {title} has closed") - await chair.send(embed=embed) - except discord.Forbidden: - user = await self.bot.fetch_user(vote.author) - await user.send(f"Your vote {title} has closed") - await user.send(embed=embed) - else: - try: - user = await self.bot.fetch_user(vote.author) - await user.send(f"Your vote {title} has closed") - await user.send(embed=embed) - except discord.Forbidden: - guild = await self.bot.fetch_guild(vote.guild) - user = await self.bot.fetch_user(guild.owner_id) - await user.send(f"A vote in your guild titled {title} has closed and the chair is unavailable.") - await user.send(embed=embed) - session.execute(delete(Votes).filter_by(vote_id=vote.id)) - session.commit() - self.vote_manager.cancel_sent_vote(vote.id, session=session) - except Exception as e: - session.execute(update(Votes).filter_by(vote_id=vote.id).values(end_time=time.time() + 86400)) - session.commit() - logger.error(f"error in vote loop: {e}") - except Exception as e: - logger.error("Exception in outer vote loop: %s" % e, exc_info=e) + await core.vote_end_loop(self.bot, core.vm) @vote_end_loop.before_loop async def before_vote_loop(self): await self.bot.wait_until_ready() + @commands.Cog.listener() async def on_raw_reaction_add(self, payload): """ Listens for when a reaction is added to a message :param payload: payload of data about the reaction """ - await self.update_vote_message(payload.message_id, payload.user_id) + await core.update_vote_message(self.bot, payload.message_id, payload.user_id) + @commands.Cog.listener() async def on_raw_reaction_remove(self, payload): @@ -150,8 +108,10 @@ async def on_raw_reaction_remove(self, payload): Listens for when a reaction is removed from a message :param payload: payload of data about the reaction """ - await self.update_vote_message(payload.message_id, payload.user_id) + await core.update_vote_message(self.bot, payload.message_id, payload.user_id) + + # how do you even procc this @commands.check(koalabot.is_admin) @commands.check(vote_is_enabled) @commands.group(name="vote") @@ -162,6 +122,7 @@ async def vote(self, ctx): if ctx.invoked_subcommand is None: await ctx.send(f"Please use `{koalabot.COMMAND_PREFIX}help vote` for more information") + @commands.check(koalabot.is_admin) @commands.check(vote_is_enabled) @vote.command(name="create") @@ -170,23 +131,8 @@ async def start_vote(self, ctx, *, title): Creates a new vote :param title: The title of the vote """ - with session_manager() as session: - if self.vote_manager.has_active_vote(ctx.author.id): - guild_name = self.bot.get_guild(self.vote_manager.get_configuring_vote(ctx.author.id).guild) - await ctx.send(f"You already have an active vote in {guild_name}. Please send that with `{koalabot.COMMAND_PREFIX}vote send` before creating a new one.") - return - - in_db = session.execute(select(Votes).filter_by(title=title, author_id=ctx.author.id)).all() - if in_db: - await ctx.send(f"You already have a vote with title {title} sent!") - return - - if len(title) > 200: - await ctx.send("Title too long") - return + await ctx.send(core.start_vote(self.bot, title, ctx.author.id, ctx.guild.id)) - self.vote_manager.create_vote(ctx.author.id, ctx.guild.id, title, session=session) - await ctx.send(f"Vote titled `{title}` created for guild {ctx.guild.name}. Use `{koalabot.COMMAND_PREFIX}help vote` to see how to configure it.") @currently_configuring() @commands.check(vote_is_enabled) @@ -197,9 +143,8 @@ async def add_role(self, ctx, *, role: discord.Role): If no roles are added, the vote will go to all users in a guild (unless a target voice channel has been set) :param role: role id (e.g. 135496683009081345) or a role ping (e.g. @Student) """ - vote = self.vote_manager.get_configuring_vote(ctx.author.id) - vote.add_role(role.id) - await ctx.send(f"Vote will be sent to those with the {role.name} role") + await ctx.send(core.set_roles(self.bot, ctx.author.id, ctx.guild.id, role.id, "add")) + @currently_configuring() @commands.check(vote_is_enabled) @@ -209,9 +154,8 @@ async def remove_role(self, ctx, *, role: discord.Role): Removes a role to the list of roles the vote will be sent to :param role: role id (e.g. 135496683009081345) or a role ping (e.g. @Student) """ - vote = self.vote_manager.get_configuring_vote(ctx.author.id) - vote.remove_role(role.id) - await ctx.send(f"Vote will no longer be sent to those with the {role.name} role") + await ctx.send(core.set_roles(self.bot, ctx.author.id, ctx.guild.id, role.id, "remove")) + @currently_configuring() @commands.check(vote_is_enabled) @@ -222,17 +166,8 @@ async def set_chair(self, ctx, *, chair: discord.Member = None): If no chair defaults to sending the message to the channel the vote is closed in :param chair: user id (e.g. 135496683009081345) or ping (e.g. @ito#8813) """ - vote = self.vote_manager.get_configuring_vote(ctx.author.id) - if chair: - try: - await chair.send(f"You have been selected as the chair for vote titled {vote.title}") - vote.set_chair(chair.id) - await ctx.send(f"Set chair to {chair.name}") - except discord.Forbidden: - await ctx.send("Chair not set as requested user is not accepting direct messages.") - else: - vote.set_chair(None) - await ctx.send(f"Results will be sent to the channel vote is closed in") + await ctx.send(await core.set_chair(self.bot, ctx.author.id, getattr(chair, 'id', None))) + @currently_configuring() @commands.check(vote_is_enabled) @@ -243,13 +178,8 @@ async def set_channel(self, ctx, *, channel: discord.VoiceChannel = None): If there isn't one set votes will go to all users in a guild (unless target roles have been added) :param channel: channel id (e.g. 135496683009081345) or mention (e.g. #cool-channel) """ - vote = self.vote_manager.get_configuring_vote(ctx.author.id) - if channel: - vote.set_vc(channel.id) - await ctx.send(f"Set target channel to {channel.name}") - else: - vote.set_vc() - await ctx.send("Removed channel restriction on vote") + await ctx.send(core.set_channel(self.bot, ctx.author.id, channel.id)) + @currently_configuring() @commands.check(vote_is_enabled) @@ -260,20 +190,12 @@ async def add_option(self, ctx, *, option_string): separate the title and description with a "+" e.g. option title+option description :param option_string: a title and description for the option separated by a '+' """ - vote = self.vote_manager.get_configuring_vote(ctx.author.id) - if len(vote.options) > 9: - await ctx.send("Vote has maximum number of options already (10)") - return - current_option_length = sum([len(x.head) + len(x.body) for x in vote.options]) - if current_option_length + len(option_string) > 1500: - await ctx.send(f"Option string is too long. The total length of all the vote options cannot be over 1500 characters.") - return if "+" not in option_string: await ctx.send("Example usage: k!vote addOption option title+option description") - return - header, body = option_string.split("+") - vote.add_option(Option(header, body, self.vote_manager.generate_unique_opt_id())) - await ctx.send(f"Option {header} with description {body} added to vote") + else: + header, body = option_string.split("+") + await ctx.send(core.add_option(ctx.author.id, header, body)) + @currently_configuring() @commands.check(vote_is_enabled) @@ -283,9 +205,8 @@ async def remove_option(self, ctx, index: int): Removes an option from a vote based on it's index :param index: the number of the option """ - vote = self.vote_manager.get_configuring_vote(ctx.author.id) - vote.remove_option(index) - await ctx.send(f"Option number {index} removed") + await ctx.send(core.remove_option(ctx.author.id, index)) + @currently_configuring() @commands.check(vote_is_enabled) @@ -297,19 +218,8 @@ async def set_end_time(self, ctx, *, time_string): :param time_string: string representing a time e.g. "2021-03-22 12:56" or "tomorrow at 10am" or "in 5 days and 15 minutes" :return: """ - now = time.time() - vote = self.vote_manager.get_configuring_vote(ctx.author.id) - cal = parsedatetime.Calendar() - end_time_readable = cal.parse(time_string)[0] - end_time = time.mktime(end_time_readable) - if (end_time - now) < 0: - await ctx.send("You can't set a vote to end in the past") - return - # if (end_time - now) < 599: - # await ctx.send("Please set the end time to be at least 10 minutes in the future.") - # return - vote.set_end_time(end_time) - await ctx.send(f"Vote set to end at {time.strftime('%Y-%m-%d %H:%M:%S', end_time_readable)} UTC") + await ctx.send(core.set_end_time(ctx.author.id, time_string)) + @currently_configuring() @commands.check(vote_is_enabled) @@ -318,9 +228,10 @@ async def preview_vote(self, ctx): """ Generates a preview of what users will see with the current configuration of the vote """ - vote = self.vote_manager.get_configuring_vote(ctx.author.id) - msg = await ctx.send(embed=create_embed(vote)) - await add_reactions(vote, msg) + prev = core.preview(ctx.author.id) + msg = await ctx.send(embed=prev[0]) + await add_reactions(prev[1], msg) + @commands.check(vote_is_enabled) @has_current_votes() @@ -330,13 +241,8 @@ async def cancel_vote(self, ctx, *, title): Cancels a vote you are setting up or have sent :param title: title of the vote to cancel """ - with session_manager() as session: - v_id = self.vote_manager.vote_lookup[(ctx.author.id, title)] - if v_id in self.vote_manager.sent_votes.keys(): - self.vote_manager.cancel_sent_vote(v_id, session=session) - else: - self.vote_manager.cancel_configuring_vote(ctx.author.id, session=session) - await ctx.send(f"Vote {title} has been cancelled.") + await ctx.send(core.cancel_vote(ctx.author.id, title)) + @commands.check(vote_is_enabled) @has_current_votes() @@ -346,14 +252,8 @@ async def check_current_votes(self, ctx): Return a list of all votes you have in this guild. :return: """ - with session_manager() as session: - embed = discord.Embed(title="Your current votes") - votes = session.execute(select(Votes.title).filter_by(author_id=ctx.author.id, guild_id=ctx.guild.id)).all() - body_string = "" - for title in votes: - body_string += f"{title[0]}\n" - embed.add_field(name="Vote Title", value=body_string, inline=False) - await ctx.send(embed=embed) + await ctx.send(embed=core.current_votes(ctx.author.id, ctx.guild.id)) + @currently_configuring() @vote.command(name="send") @@ -361,34 +261,8 @@ async def send_vote(self, ctx): """ Sends a vote to all users within the restrictions set with the current options added """ - vote = self.vote_manager.get_configuring_vote(ctx.author.id) - - if not vote.is_ready(): - await ctx.send("Please add more than 1 option to vote for") - return - - self.vote_manager.configuring_votes.pop(ctx.author.id) - self.vote_manager.sent_votes[vote.id] = vote - - users = [x for x in ctx.guild.members if not x.bot] - if vote.target_voice_channel: - vc_users = discord.utils.get(ctx.guild.voice_channels, id=vote.target_voice_channel).members - users = list(set(vc_users) & set(users)) - if vote.target_roles: - role_users = [] - for role_id in vote.target_roles: - role = discord.utils.get(ctx.guild.roles, id=role_id) - role_users += role.members - role_users = list(dict.fromkeys(role_users)) - users = list(set(role_users) & set(users)) - for user in users: - try: - msg = await user.send(f"You have been asked to participate in this vote from {ctx.guild.name}.\nPlease react to make your choice (You can change your mind until the vote is closed)", embed=create_embed(vote)) - vote.register_sent(user.id, msg.id) - await add_reactions(vote, msg) - except discord.Forbidden: - logger.error(f"tried to send vote to user {user.id} but direct messages are turned off.") - await ctx.send(f"Sent vote to {len(users)} users") + await ctx.send(await core.send_vote(self.bot, ctx.author.id, ctx.guild)) + @commands.check(vote_is_enabled) @has_current_votes() @@ -397,29 +271,14 @@ async def close(self, ctx, *, title): """ Ends a vote, and collects the results """ - vote_id = self.vote_manager.vote_lookup[(ctx.author.id, title)] - if vote_id not in self.vote_manager.sent_votes.keys(): - if ctx.author.id in self.vote_manager.configuring_votes.keys(): - await ctx.send(f"That vote has not been sent yet. Please send it to your audience with {koalabot.COMMAND_PREFIX}vote send {title}") - else: - await ctx.send("You have no votes of that title to close") - return - - vote = self.vote_manager.get_vote_from_id(vote_id) - results = await get_results(self.bot, vote) - with session_manager() as session: - self.vote_manager.cancel_sent_vote(vote.id, session=session) - embed = await make_result_embed(vote, results) - if vote.chair: - try: - chair = await self.bot.fetch_user(vote.chair) - await chair.send(embed=embed) - await ctx.send(f"Sent results to {chair}") - except discord.Forbidden: - await ctx.send("Chair does not accept direct messages, sending results here.") - await ctx.send(embed=embed) + msg = await core.close(self.bot, ctx.author.id, title) + if type(msg) is list: + await ctx.send(msg[0], embed=msg[1]) + elif type(msg) is discord.Embed: + await ctx.send(embed=msg) else: - await ctx.send(embed=embed) + await ctx.send(msg) + @commands.check(vote_is_enabled) @has_current_votes() @@ -428,44 +287,11 @@ async def check_results(self, ctx, *, title): """ Checks the results of a vote without closing it """ - vote_id = self.vote_manager.vote_lookup.get((ctx.author.id, title)) - if vote_id is None: - raise ValueError(f"{title} is not a valid vote title for user {ctx.author.name}") - - if vote_id not in self.vote_manager.sent_votes.keys(): - if ctx.author.id in self.vote_manager.configuring_votes.keys(): - await ctx.send( - f"That vote has not been sent yet. Please send it to your audience with {koalabot.COMMAND_PREFIX}vote send {title}") - else: - await ctx.send("You have no votes of that title to check") - return - - vote = self.vote_manager.get_vote_from_id(vote_id) - results = await get_results(self.bot, vote) - embed = await make_result_embed(vote, results) - await ctx.send(embed=embed) - - async def update_vote_message(self, message_id, user_id): - """ - Updates the vote message with the currently selected option - :param message_id: id of the message that was reacted on - :param user_id: id of the user who reacted - """ - vote = self.vote_manager.was_sent_to(message_id) - user = self.bot.get_user(user_id) - if vote and not user.bot: - msg = await user.fetch_message(message_id) - embed = msg.embeds[0] - choice = None - for reaction in msg.reactions: - if reaction.count > 1: - choice = reaction - break - if choice: - embed.set_footer(text=f"You will be voting for {choice.emoji} - {vote.options[VoteManager.emote_reference[choice.emoji]].head}") - else: - embed.set_footer(text="There are no valid choices selected") - await msg.edit(embed=embed) + msg = await core.results(self.bot, ctx.author.id, title) + if type(msg) is discord.Embed: + await ctx.send(embed=msg) + else: + await ctx.send(msg) async def setup(bot: koalabot) -> None: @@ -474,4 +300,3 @@ async def setup(bot: koalabot) -> None: :param bot: the bot client for KoalaBot """ await bot.add_cog(Voting(bot)) - logger.info("Voting is ready.") diff --git a/koala/cogs/voting/core.py b/koala/cogs/voting/core.py new file mode 100644 index 00000000..36005e73 --- /dev/null +++ b/koala/cogs/voting/core.py @@ -0,0 +1,287 @@ +# Built-in/Generic Imports +import time + +# Libs +import discord +import parsedatetime.parsedatetime +from sqlalchemy import select, delete, update +from sqlalchemy.orm import Session + +# Own modules +import koalabot +from koala.db import assign_session +from .db import VoteManager, get_results, create_embed, add_reactions +from .log import logger +from .models import Votes +from .option import Option +from .utils import make_result_embed + + +vm = VoteManager() +vm.load_from_db() + + +async def update_vote_message(bot: koalabot.KoalaBot, message_id, user_id): + """ + Updates the vote message with the currently selected option + :param message_id: id of the message that was reacted on + :param user_id: id of the user who reacted + """ + vote = bot.vote_manager.was_sent_to(message_id) + user = bot.bot.get_user(user_id) + if vote and not user.bot: + msg = await user.fetch_message(message_id) + embed = msg.embeds[0] + choice = None + for reaction in msg.reactions: + if reaction.count > 1: + choice = reaction + break + if choice: + embed.set_footer(text=f"You will be voting for {choice.emoji} - {vote.options[VoteManager.emote_reference[choice.emoji]].head}") + else: + embed.set_footer(text="There are no valid choices selected") + await msg.edit(embed=embed) + + +@assign_session +async def vote_end_loop(bot: koalabot.KoalaBot, session: Session): + try: + now = time.time() + votes = session.execute(select(Votes.vote_id, Votes.author_id, Votes.guild_id, Votes.title, Votes.end_time) + .where(Votes.end_time < now)).all() + for v_id, a_id, g_id, title, end_time in votes: + if v_id in vm.sent_votes.keys(): + vote = vm.get_vote_from_id(v_id) + results = await get_results(bot, vote) + embed = await make_result_embed(vote, results) + try: + if vote.chair: + try: + chair = await bot.fetch_user(vote.chair) + await chair.send(f"Your vote {title} has closed") + await chair.send(embed=embed) + except discord.Forbidden: + user = await bot.fetch_user(vote.author) + await user.send(f"Your vote {title} has closed") + await user.send(embed=embed) + else: + try: + user = await bot.fetch_user(vote.author) + await user.send(f"Your vote {title} has closed") + await user.send(embed=embed) + except discord.Forbidden: + guild = await bot.fetch_guild(vote.guild) + user = await bot.fetch_user(guild.owner_id) + await user.send(f"A vote in your guild titled {title} has closed and the chair is unavailable.") + await user.send(embed=embed) + session.execute(delete(Votes).filter_by(vote_id=vote.id)) + session.commit() + vm.cancel_sent_vote(vote.id) + except Exception as e: + session.execute(update(Votes).filter_by(vote_id=vote.id).values(end_time=time.time() + 86400)) + session.commit() + logger.error(f"error in vote loop: {e}") + except Exception as e: + logger.error("Exception in outer vote loop: %s" % e, exc_info=e) + + +@assign_session +def start_vote(bot: koalabot.KoalaBot, title, author_id, guild_id, session: Session): + guild_name = bot.get_guild(guild_id) + + if vm.has_active_vote(author_id): + return f"You already have an active vote in {guild_name}. Please send that with `{koalabot.COMMAND_PREFIX}vote send` before creating a new one." + + in_db = session.execute(select(Votes).filter_by(title=title, author_id=author_id)).all() + if in_db: + return f"You already have a vote with title {title} sent!" + + if len(title) > 200: + return "Title too long" + + vm.create_vote(author_id, guild_id, title, session) + return f"Vote titled `{title}` created for guild {guild_name}. Use `{koalabot.COMMAND_PREFIX}help vote` to see how to configure it." + + +def set_roles(bot: koalabot.KoalaBot, author_id, guild_id, role_id, action): + vote = vm.get_configuring_vote(author_id) + role = bot.get_guild(guild_id).get_role(role_id) + + if action == "add": + vote.add_role(role_id) + return f"Vote will be sent to those with the {role.name} role" + + if action == "remove": + vote.remove_role(role_id) + return f"Vote will no longer be sent to those with the {role.name} role" + + +async def set_chair(bot: koalabot.KoalaBot, author_id, chair_id=None): + vote = vm.get_configuring_vote(author_id) + + if chair_id: + chair = bot.get_user(chair_id) + try: + await chair.send(f"You have been selected as the chair for vote titled {vote.title}") + vote.set_chair(chair.id) + return f"Set chair to {chair.name}" + except discord.Forbidden: + return "Chair not set as requested user is not accepting direct messages." + else: + vote.set_chair(None) + return "Results will be sent to the channel vote is closed in" + + +def set_channel(bot: koalabot.KoalaBot, author_id, channel_id=None): + vote = vm.get_configuring_vote(author_id) + channel = bot.get_channel(channel_id) + + if channel: + vote.set_vc(channel.id) + return f"Set target channel to {channel.name}" + else: + vote.set_vc() + return "Removed channel restriction on vote" + + +def add_option(author_id, option_header, option_body): + vote = vm.get_configuring_vote(author_id) + + if len(vote.options) > 9: + return "Vote has maximum number of options already (10)" + + current_option_length = sum([len(x.head) + len(x.body) for x in vote.options]) + + if current_option_length + len(option_header) + len(option_body) > 1500: + return "Option string is too long. The total length of all the vote options cannot be over 1500 characters." + + vote.add_option(Option(option_header, option_body, vm.generate_unique_opt_id())) + return f"Option {option_header} with description {option_body} added to vote" + + +def remove_option(author_id, index): + vote = vm.get_configuring_vote(author_id) + try: + vote.remove_option(index) + return f"Option number {index} removed" + except IndexError: + return f"Option number {index} not found" + + +def set_end_time(author_id, time_string): + now = time.time() + vote = vm.get_configuring_vote(author_id) + cal = parsedatetime.Calendar() + end_time_readable = cal.parse(time_string)[0] + end_time = time.mktime(end_time_readable) + if (end_time - now) < 0: + return "You can't set a vote to end in the past" + # if (end_time - now) < 599: + # return "Please set the end time to be at least 10 minutes in the future." + vote.set_end_time(end_time) + return f"Vote set to end at {time.strftime('%Y-%m-%d %H:%M:%S', end_time_readable)} UTC" + + +def preview(author_id): + vote = vm.get_configuring_vote(author_id) + return [create_embed(vote), vote] + + +def cancel_vote(author_id, title): + v_id = vm.vote_lookup[(author_id, title)] + if v_id in vm.sent_votes.keys(): + vm.cancel_sent_vote(v_id) + else: + vm.cancel_configuring_vote(author_id) + return f"Vote {title} has been cancelled." + + +@assign_session +def current_votes(author_id, guild_id, session: Session): + embed = discord.Embed(title="Your current votes") + votes = session.execute(select(Votes.title).filter_by(author_id=author_id, guild_id=guild_id)).all() + body_string = "" + if len(votes) > 0: + for title in votes: + body_string += f"{title[0]}\n" + embed.add_field(name="Vote Title", value=body_string, inline=False) + else: + embed.description = "No current votes" + return embed + + +async def send_vote(bot: koalabot.KoalaBot, author_id, guild_id): + vote = vm.get_configuring_vote(author_id) + guild = bot.get_guild(guild_id) + + if not vote.is_ready(): + return "Please add more than 1 option to vote for" + + vm.configuring_votes.pop(author_id) + vm.sent_votes[vote.id] = vote + + users = [x for x in guild.members if not x.bot] + if vote.target_voice_channel: + vc_users = discord.utils.get(guild.voice_channels, id=vote.target_voice_channel).members + users = list(set(vc_users) & set(users)) + if vote.target_roles: + role_users = [] + for role_id in vote.target_roles: + role = discord.utils.get(guild.roles, id=role_id) + role_users += role.members + role_users = list(dict.fromkeys(role_users)) + users = list(set(role_users) & set(users)) + for user in users: + try: + msg = await user.send(f"You have been asked to participate in this vote from {guild.name}.\nPlease react to make your choice (You can change your mind until the vote is closed)", embed=create_embed(vote)) + vote.register_sent(user.id, msg.id) + await add_reactions(vote, msg) + except discord.Forbidden: + logger.error(f"tried to send vote to user {user.id} but direct messages are turned off.") + return f"Sent vote to {len(users)} users" + + +async def close(bot: koalabot.KoalaBot, author_id, title): + vote_id = vm.vote_lookup[(author_id, title)] + if vote_id not in vm.sent_votes.keys(): + if author_id in vm.configuring_votes.keys(): + return f"That vote has not been sent yet. Please send it to your audience with {koalabot.COMMAND_PREFIX}vote send {title}" + else: + return "You have no votes of that title to close" + + vote = vm.get_vote_from_id(vote_id) + results = await get_results(bot, vote) + vm.cancel_sent_vote(vote.id) + embed = await make_result_embed(vote, results) + if vote.chair: + try: + chair = await bot.fetch_user(vote.chair) + await chair.send(embed=embed) + return f"Sent results to {chair}" + except discord.Forbidden: + return ["Chair does not accept direct messages, sending results here.", embed] + else: + return embed + + +async def results(bot: koalabot.KoalaBot, author_id, title): + try: + vote_id = vm.vote_lookup[(author_id, title)] + except KeyError as e: + logger.error(e) + raise ValueError(f"{title} is not a valid vote title for user with id {author_id}") + + if vote_id is None: + raise ValueError(f"{title} is not a valid vote title for user with id {author_id}") + + if vote_id not in vm.sent_votes.keys(): + if author_id in vm.configuring_votes.keys(): + return f"That vote has not been sent yet. Please send it to your audience with {koalabot.COMMAND_PREFIX}vote send {title}" + else: + return "You have no votes of that title to check" + + vote = vm.get_vote_from_id(vote_id) + results = await get_results(bot, vote) + embed = await make_result_embed(vote, results) + return embed \ No newline at end of file diff --git a/koala/cogs/voting/db.py b/koala/cogs/voting/db.py index eaf6b1ed..f780c5b3 100644 --- a/koala/cogs/voting/db.py +++ b/koala/cogs/voting/db.py @@ -10,6 +10,7 @@ # Libs import discord from sqlalchemy import select, delete +from sqlalchemy.orm import Session # Own modules from koala.db import session_manager diff --git a/tests/cogs/voting/test_api.py b/tests/cogs/voting/test_api.py new file mode 100644 index 00000000..d64ff580 --- /dev/null +++ b/tests/cogs/voting/test_api.py @@ -0,0 +1,164 @@ +from http.client import BAD_REQUEST, CREATED, OK, UNPROCESSABLE_ENTITY, NOT_FOUND + +# Libs +import discord +import discord.ext.test as dpytest +import pytest +from aiohttp import web + +from koala.cogs.voting.api import VotingEndpoint + +# Variables +options = [ + { + "header": "option1", + "body": "option1desc" + }, + { + "header": "option2", + "body": "option2desc" + } + ] + +@pytest.fixture +def api_client(bot: discord.ext.commands.Bot, aiohttp_client, loop): + app = web.Application() + endpoint = VotingEndpoint(bot) + app = endpoint.register(app) + return loop.run_until_complete(aiohttp_client(app)) + + +# POST /config +# not sure how to test for any error handling + +async def test_post_new_vote_no_optionals(api_client): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + resp = await api_client.post('/config', json= + { + 'title': 'Test', + 'author_id': author.id, + 'guild_id': guild.id, + 'options': options + }) + + assert resp.status == CREATED + assert (await resp.json())['message'] == "Vote Test created" + + +async def test_post_new_vote_with_optionals(api_client): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + resp = await api_client.post('/config', json= + { + 'title': 'Test2', + 'author_id': author.id, + 'guild_id': guild.id, + 'options': options, + 'roles': [guild.roles[0].id], + 'chair_id': guild.members[1].id, + 'end_time': '2025-01-01 00:00:00' + }) + + assert resp.status == CREATED + assert (await resp.json())['message'] == "Vote Test2 created" + + +# GET /config + +async def test_get_current_votes(api_client): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + await api_client.post('/config', json= + { + 'title': 'Test', + 'author_id': author.id, + 'guild_id': guild.id, + 'options': options + }) + + resp = await api_client.get('/config?author_id={}&guild_id={}'.format(author.id, guild.id)) + assert resp.status == OK + + jresp = await resp.json() + + assert jresp['embed_title'] == "Your current votes" + assert jresp['embed_body'] == "Test\n" + + +async def test_get_current_votes_no_votes(api_client): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + resp = await api_client.get('/config?author_id={}&guild_id={}'.format(author.id, guild.id)) + assert resp.status == OK + + jresp = await resp.json() + + assert jresp['embed_title'] == "Your current votes" + assert jresp['embed_body'] == "No current votes" + + +# POST /results +async def test_post_results(api_client): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + await api_client.post('/config', json= + { + 'title': 'Test', + 'author_id': author.id, + 'guild_id': guild.id, + 'options': options + }) + + resp = await api_client.post('/results', json={ + 'author_id': author.id, + 'title': 'Test' + }) + + assert resp.status == OK + jresp = await resp.json() + + assert jresp['embed_title'] == "Test Results:" + assert jresp['embed_body'] == "option1, 1 votes\noption2, 0 votes\n" + # how the hell is this getting votes? + + resp = await api_client.get('/config?author_id={}&guild_id={}'.format(author.id, guild.id)) + + assert (await resp.json())['embed_body'] == "No current votes" + + +# GET /results +async def test_get_results(api_client): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + resp2 = await api_client.post('/config', json= + { + 'title': 'Test2', + 'author_id': author.id, + 'guild_id': guild.id, + 'options': options + }) + + assert resp2.status == CREATED + assert (await resp2.json())['message'] == "Vote Test2 created" + + # for SOME REASON it thinks its an invalid vote; the post is fine + + resp = await api_client.get('/results?author_id={}&title=Test2'.format(author.id)) + assert resp.status == OK + jresp = await resp.json() + + assert jresp['embed_title'] == "Test2 Results:" + assert jresp['embed_body'] == "option1, 1 votes\noption2, 0 votes\n" + + # checking vote hasn't closed + resp = await api_client.get('/config?author_id={}&guild_id={}'.format(author.id, guild.id)) + + assert (await resp.json())['embed_body'] == "Test2\n" + \ No newline at end of file diff --git a/tests/cogs/voting/test_cog.py b/tests/cogs/voting/test_cog.py index 80f9800f..b52d0fa2 100644 --- a/tests/cogs/voting/test_cog.py +++ b/tests/cogs/voting/test_cog.py @@ -9,6 +9,7 @@ # Built-in/Generic Imports # Libs +import discord import discord.ext.test as dpytest import pytest import pytest_asyncio @@ -18,6 +19,7 @@ # Own modules import koalabot from koala.cogs import Voting +from koala.cogs.voting import core from koala.cogs.voting.models import Votes from koala.db import session_manager, insert_extension from tests.log import logger @@ -36,8 +38,7 @@ async def cog(bot: commands.Bot): @pytest.mark.asyncio async def test_discord_create_vote(): with session_manager() as session: - config = dpytest.get_config() - guild = config.guilds[0] + guild: discord.Guild = dpytest.get_config().guilds[0] await dpytest.message(f"{koalabot.COMMAND_PREFIX}vote create Test Vote") assert dpytest.verify().message().content( f"Vote titled `Test Vote` created for guild {guild.name}. Use `{koalabot.COMMAND_PREFIX}help vote`" @@ -80,10 +81,12 @@ async def test_discord_vote_add_and_remove_role(cog): assert dpytest.verify().message().content( f"Vote titled `Test Vote` created for guild {guild.name}. Use `{koalabot.COMMAND_PREFIX}help vote` to see how " f"to configure it.") + await dpytest.message(f"{koalabot.COMMAND_PREFIX}vote addRole {guild.roles[0].id}") assert dpytest.verify().message().content(f"Vote will be sent to those with the {guild.roles[0].name} role") - vote = cog.vote_manager.get_configuring_vote(guild.members[0].id) + vote = core.vm.get_configuring_vote(guild.members[0].id) assert guild.roles[0].id in vote.target_roles + await dpytest.message(f"{koalabot.COMMAND_PREFIX}vote removeRole {guild.roles[0].id}") assert dpytest.verify().message().content( f"Vote will no longer be sent to those with the {guild.roles[0].name} role") diff --git a/tests/cogs/voting/test_core.py b/tests/cogs/voting/test_core.py new file mode 100644 index 00000000..2a5420b6 --- /dev/null +++ b/tests/cogs/voting/test_core.py @@ -0,0 +1,351 @@ +#Libs +import discord +import discord.ext.test as dpytest +import mock +import pytest +import pytest_asyncio +from discord.ext import commands +from sqlalchemy import select +from koala.cogs import Voting +from koala.cogs.voting.models import Votes +from koala.cogs.voting.option import Option + +# Own modules +import koalabot +from koala.db import assign_session +from tests.log import logger +from koala.cogs.voting import core + +# Variables +opt1_header = "option1" +opt1_body = "desc1" +opt2_header = "option2" +opt2_body = "desc2" + + +@pytest_asyncio.fixture(scope="function", autouse=True) +async def cog(bot: commands.Bot): + cog = Voting(bot) + await bot.add_cog(cog) + dpytest.configure(bot) + logger.info("Tests starting") + return cog + + +def test_create_vote(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + assert core.start_vote(bot, "Test Vote", author.id, guild.id) == f"Vote titled `Test Vote` created for guild {guild.name}. Use `{koalabot.COMMAND_PREFIX}help vote` to see how to configure it." + + +@pytest.mark.asyncio +async def test_vote_already_created(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + await dpytest.message(f"{koalabot.COMMAND_PREFIX}vote create Test Vote") + + assert core.start_vote(bot, "Test Vote", author.id, guild.id) == f"You already have an active vote in {guild.name}. Please send that with `{koalabot.COMMAND_PREFIX}vote send` before creating a new one." + + +@assign_session +@pytest.mark.asyncio +async def test_vote_already_sent(bot: commands.Bot, cog, session): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + session.add(Votes(vote_id=111, author_id=author.id, guild_id=guild.id, title="Test Vote")) + session.commit() + + assert core.start_vote(bot, "Test Vote", author.id, guild.id) == "You already have a vote with title Test Vote sent!" + + +@pytest.mark.asyncio +async def test_add_role(bot: commands.Bot, cog, session): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + role: discord.Role = dpytest.back.make_role("testRole", guild, id_num=555) + + await dpytest.message(f"{koalabot.COMMAND_PREFIX}vote create Test Vote") + assert core.set_roles(bot, author.id, guild.id, role.id, "add") == f"Vote will be sent to those with the {role.name} role" + + +@pytest.mark.asyncio +async def test_remove_role(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + role: discord.Role = dpytest.back.make_role("testRole", guild, id_num=555) + + await dpytest.message(f"{koalabot.COMMAND_PREFIX}vote create Test Vote") + core.set_roles(bot, author.id, guild.id, role.id, "add") + + assert core.set_roles(bot, author.id, guild.id, role.id, "remove") == f"Vote will no longer be sent to those with the {role.name} role" + + +@pytest.mark.asyncio +async def test_set_chair(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + chair: discord.Member = guild.members[1] + core.start_vote(bot, "Test Vote", author.id, guild.id) + + assert await core.set_chair(bot, author.id, chair.id) == f"Set chair to {chair.name}" + + +# failing because idk how to mock a blocked dm channel +@pytest.mark.asyncio +async def test_set_chair_no_dms(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + chair: discord.Member = guild.members[1] + core.start_vote(bot, "Test Vote", author.id, guild.id) + + # dpytest.back.start_private_message? + # pytest.raises is NOT the way to go here. the Forbidden is excepted, not thrown. + with mock.patch('discord.channel.DMChannel', mock.Mock(side_effect=Exception('discord.Forbidden'))): + with pytest.raises(discord.Forbidden, match="Chair not set as requested user is not accepting direct messages."): + await core.set_chair(bot, author.id, chair.id) + + +@pytest.mark.asyncio +async def test_set_no_chair(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + + assert await core.set_chair(bot, author.id) == "Results will be sent to the channel vote is closed in" + + +# make_voice_channel doesn't exist even though it's in their documentation +def test_set_channel(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + channel = dpytest.back.make_voice_channel("Voice Channel", guild) + + core.start_vote(bot, "Test Vote", author.id, guild.id) + + assert core.set_channel(bot, author.id, channel.id) == f"Set target channel to {channel.name}" + + +def test_set_no_channel(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + core.start_vote(bot, "Test Vote", author.id, guild.id) + + assert core.set_channel(bot, author.id) == "Removed channel restriction on vote" + + +def test_add_option(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + core.start_vote(bot, "Test Vote", author.id, guild.id) + + assert core.add_option(author.id, opt1_header, opt1_body) == "Option option1 with description desc1 added to vote" + + +def test_add_option_too_many(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + core.start_vote(bot, "Test Vote", author.id, guild.id) + + x = 0 + while (x < 11): + core.add_option(author.id, opt1_header, opt1_body) + x += 1 + + assert core.add_option(author.id, opt1_header, opt1_body) == "Vote has maximum number of options already (10)" + + +def test_add_option_too_long(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + + test_option_header = "i am trying to write a lot of words here. needs to be over fifteen thousand words to be exact. but i'm separating it so it does not all get clustered into the same paragraph and become a word soup" + test_option_body = 'i was wrong, it is actually fifteen hundred words. whoever actually reads this will get a little entertainment i hope. is there a better way to test this? probably.' + x = 0 + while (x < 5): + core.add_option(author.id, test_option_header, test_option_body) + x += 1 + + assert core.add_option(author.id, test_option_header, test_option_body) == "Option string is too long. The total length of all the vote options cannot be over 1500 characters." + + +def test_remove_option(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + + core.add_option(author.id, opt1_header, opt1_body) + + assert core.remove_option(author.id, 0) == "Option number 0 removed" + + +def test_remove_option_bad(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + + assert core.remove_option(author.id, 0) == "Option number 0 not found" + + +def test_set_end_time(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + + assert core.set_end_time(author.id, "2222-12-30 13:30") == "Vote set to end at 2222-12-30 13:30:00 UTC" + + +def test_set_impossible_end_time(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + + assert core.set_end_time(author.id, "2020-01-15 12:50") == "You can't set a vote to end in the past" + + +def test_preview(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + + prev = core.preview(author.id) + assert prev[0].title == "Test Vote" + + +@pytest.mark.asyncio +async def test_cancel_sent_vote(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + core.add_option(author.id, opt1_header, opt1_body) + core.add_option(author.id, opt2_header, opt2_body) + + await core.send_vote(bot, author.id, guild.id) + + assert core.cancel_vote(author.id, "Test Vote") == "Vote Test Vote has been cancelled." + + +def test_cancel_unsent_vote(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + + assert core.cancel_vote(author.id, "Test Vote") == "Vote Test Vote has been cancelled." + + +def test_current_votes(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + + embed = core.current_votes(author.id, guild.id) + assert embed.title == "Your current votes" + + +def test_current_votes_no_votes(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + embed = core.current_votes(author.id, guild.id) + assert embed.title == "Your current votes" + assert embed.description == "No current votes" + + +@pytest.mark.asyncio +async def test_close_no_chair(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + core.add_option(author.id, opt1_header, opt1_body) + core.add_option(author.id, opt2_header, opt2_body) + + await core.send_vote(bot, author.id, guild.id) + + embed = await core.close(bot, author.id, "Test Vote") + assert embed.title == "Test Vote Results:" + assert embed.fields[0].name == "option1" + assert embed.fields[1].name == "option2" + + +@pytest.mark.asyncio +async def test_close_with_chair(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + chair: discord.Member = guild.members[1] + core.start_vote(bot, "Test Vote", author.id, guild.id) + core.add_option(author.id, opt1_header, opt1_body) + core.add_option(author.id, opt2_header, opt2_body) + await core.set_chair(bot, author.id, chair.id) + + await core.send_vote(bot, author.id, guild.id) + + assert await core.close(bot, author.id, "Test Vote") == f"Sent results to {chair}" + + +@pytest.mark.asyncio +async def test_send_vote(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + core.add_option(author.id, opt1_header, opt1_body) + core.add_option(author.id, opt2_header, opt2_body) + + # not sure how to assert DM sent + + assert await core.send_vote(bot, author.id, guild.id) == "Sent vote to 1 users" + + +@pytest.mark.asyncio +async def test_send_vote_bad_options(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + + # no options + assert await core.send_vote(bot, author.id, guild.id) == "Please add more than 1 option to vote for" + + # only 1 option + core.add_option(author.id, opt1_header, opt1_body) + assert await core.send_vote(bot, author.id, guild.id) == "Please add more than 1 option to vote for" + + +@pytest.mark.asyncio +async def test_get_results(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + core.add_option(author.id, opt1_header, opt1_body) + core.add_option(author.id, opt2_header, opt2_body) + + await core.send_vote(bot, author.id, guild.id) + + embed = await core.results(bot, author.id, "Test Vote") + assert embed.title == "Test Vote Results:" + assert embed.fields[0].name == "option1" + assert embed.fields[1].name == "option2" + + +@pytest.mark.asyncio +async def test_results_vote_not_sent(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + core.start_vote(bot, "Test Vote", author.id, guild.id) + core.add_option(author.id, opt1_header, opt1_body) + core.add_option(author.id, opt2_header, opt2_body) + + assert await core.results(bot, author.id, "Test Vote") == "That vote has not been sent yet. Please send it to your audience with k!vote send Test Vote" + + +@pytest.mark.asyncio +async def test_results_invalid_vote(bot: commands.Bot, cog): + guild: discord.Guild = dpytest.get_config().guilds[0] + author: discord.Member = guild.members[0] + + with pytest.raises(ValueError, match=f"invalid is not a valid vote title for user with id {author.id}"): + await core.results(bot, author.id, "invalid") \ No newline at end of file