Skip to content

Commit

Permalink
Úpravy anket (#24)
Browse files Browse the repository at this point in the history
Pull request míří na úpravu anket podle issues. Zejména se jedná o tyto
věci:

- Přidání možnosti po vytvoření ankety
- Anketa, když jí vyprší čas, by se měla automaticky smazat a odeslat
prázdný embed s pouze hlasy.
- Malé grafické úpravy

Tyto změny by měly obecně prospět anketám a byly by mnohem přehlednější.

Související issues: 

- [x] #29 
- [x] #22 
- [x] #21 

TODO: 

- [x] Code cleanup
- [x] Přidat type hinting
- [ ] Ozkoušet a otestovat kód
- [x] Přidat error handling
- [x] Vyřešit bug, že `raise` keyword automaticky nepošle error. 

#30 bude místo, kde se bude hromadně testovat každá funkcionalita Jáchyma. Pro teď merguji.
  • Loading branch information
TheXer authored Aug 25, 2023
2 parents d999fa1 + 9f6c9ad commit 2c5cbea
Show file tree
Hide file tree
Showing 20 changed files with 346 additions and 96 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ __pycache__
/discord.log
.DS_STORE
.vscode
.ruff_cache
.pytest_cache
25 changes: 1 addition & 24 deletions cogs/error.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from discord import Interaction
from discord.app_commands import CommandInvokeError
from discord.ext import commands
from loguru import logger

from src.ui.embeds import ErrorMessage
from src.ui.error_view import PrettyError


class Error(commands.Cog):
Expand Down Expand Up @@ -32,28 +32,5 @@ async def on_app_command_error(self, interaction: Interaction, error: Exception)
)


class PrettyError(CommandInvokeError):
"""Pretty errors useful for raise keyword"""

def __init__(self, message: str, interaction: Interaction, inner_exception: Exception | None = None):
super().__init__(interaction.command, inner_exception)
self.message = message
self.interaction = interaction

async def send(self):
if not self.interaction.response.is_done():
await self.interaction.response.send_message(embed=ErrorMessage(self.message))
else:
await self.interaction.followup.send(embed=ErrorMessage(self.message))


class TooManyOptionsError(PrettyError):
pass


class TooFewOptionsError(PrettyError):
pass


async def setup(bot):
await bot.add_cog(Error(bot))
75 changes: 37 additions & 38 deletions cogs/poll_command.py
Original file line number Diff line number Diff line change
@@ -1,49 +1,18 @@
import re
import asyncio
import datetime

import discord
from discord import app_commands
from discord.app_commands import Transform, Transformer
from discord.ext import commands
from discord.app_commands import Transform
from discord.ext import commands, tasks
from loguru import logger

from cogs.error import TooFewOptionsError, TooManyOptionsError
from src.db_folder.databases import PollDatabase, VoteButtonDatabase
from src.jachym import Jachym
from src.ui.embeds import PollEmbed, PollEmbedBase
from src.ui.poll import Poll
from src.ui.poll_view import PollView


class OptionsTransformer(Transformer):
async def transform(
self, interaction: discord.Interaction, option: str
) -> TooManyOptionsError | TooFewOptionsError | list[str]:
"""
Transformer method to transformate a single string to multiple options. If they are not within parameters,
raises an error, else returns options.
Parameters
----------
interaction: discord.Interaction
option: str
Returns
-------
List of strings
Raises:
-------
TooManyOptionsError, TooFewOptionsError
"""
answers = [option for option in re.split('"|"|“|„', option) if option.strip()]
if len(answers) > Poll.MAX_OPTIONS:
msg = f"Zadal jsi příliš mnoho odpovědí, můžeš maximálně {Poll.MAX_OPTIONS}!"
raise TooManyOptionsError(msg, interaction)
if len(answers) < Poll.MIN_OPTIONS:
msg = f"Zadal jsi příliš málo odpovědí, můžeš alespoň {Poll.MIN_OPTIONS}!"
raise TooFewOptionsError(msg, interaction)
return answers
from src.ui.transformers import DatetimeTransformer, OptionsTransformer


class PollCreate(commands.Cog):
Expand All @@ -54,16 +23,22 @@ def __init__(self, bot: Jachym):
name="anketa",
description="Anketa pro hlasování. Jsou vidět všichni hlasovatelé.",
)
@app_commands.rename(question="otázka", answer="odpovědi")
@app_commands.rename(
question="otázka",
answer="odpovědi",
date_time="datum",
)
@app_commands.describe(
question="Otázka, kterou chceš položit.",
answer='Odpovědi, rozděluješ odpovědi uvozovkou ("), maximálně pouze 10 možností',
date_time="Den, na který anketa skončí.",
)
async def pool(
self,
interaction: discord.Interaction,
question: str,
answer: Transform[list[str, ...], OptionsTransformer],
date_time: Transform[datetime.datetime, DatetimeTransformer] | None,
) -> discord.Message:
await interaction.response.send_message(embed=PollEmbedBase("Nahrávám anketu..."))
message = await interaction.original_response()
Expand All @@ -74,18 +49,42 @@ async def pool(
question=question,
options=answer,
user_id=interaction.user.id,
date_created=date_time,
)

embed = PollEmbed(poll)
view = PollView(poll, embed, db_poll=self.bot.pool)
await PollDatabase(self.bot.pool).add(poll)
await VoteButtonDatabase(self.bot.pool).add_options(poll)

self.bot.active_discord_polls.add(poll)
self.bot.active_discord_polls.add((poll, message))
await self.bot.set_presence()

logger.info(f"Successfully added Pool - {message.id}")
return await message.edit(embed=embed, view=view)


class PollTaskLoops(commands.Cog):
def __init__(self, bot: Jachym):
self.bot = bot
self.send_completed_pool.start()

@tasks.loop(seconds=5)
async def send_completed_pool(self):
for poll, message in self.bot.active_discord_polls.copy():
if poll.created_at is None or datetime.datetime.now() < poll.created_at:
continue

embed = message.embeds[0]
embed.title = f"{embed.title[0]} [UZAVŘENO] {embed.title[1:]}"
channel = self.bot.get_channel(poll.channel_id)
await channel.send(embed=embed)

asyncio.create_task(PollDatabase(self.bot.pool).remove(poll.message_id))
asyncio.create_task(message.delete())
self.bot.active_discord_polls.remove((poll, message))


async def setup(bot):
await bot.add_cog(PollCreate(bot))
await bot.add_cog(PollTaskLoops(bot))
2 changes: 2 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
from os import getenv

import discord.utils
from dotenv import load_dotenv

from src.jachym import Jachym
Expand All @@ -11,6 +12,7 @@
async def main() -> None:
bot = Jachym()
async with bot:
discord.utils.setup_logging()
await bot.load_extensions()
await bot.start(getenv("DISCORD_TOKEN"))

Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ python-dotenv==0.17.1

aiomysql>=0.0.22
pytest>=7.3.1
loguru>=0.7.0
loguru>=0.7.0
dateparser>=1.1.8
4 changes: 4 additions & 0 deletions src/UNKNOWN.egg-info/PKG-INFO
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Metadata-Version: 2.1
Name: UNKNOWN
Version: 0.0.0
License-File: LICENSE
19 changes: 19 additions & 0 deletions src/UNKNOWN.egg-info/SOURCES.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
LICENSE
pyproject.toml
src/__init__.py
src/helpers.py
src/jachym.py
src/UNKNOWN.egg-info/PKG-INFO
src/UNKNOWN.egg-info/SOURCES.txt
src/UNKNOWN.egg-info/dependency_links.txt
src/UNKNOWN.egg-info/top_level.txt
src/db_folder/databases.py
src/ui/__init__.py
src/ui/button.py
src/ui/embeds.py
src/ui/emojis.py
src/ui/error_view.py
src/ui/modals.py
src/ui/poll.py
src/ui/poll_view.py
tests/test_pool.py
1 change: 1 addition & 0 deletions src/UNKNOWN.egg-info/dependency_links.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

6 changes: 6 additions & 0 deletions src/UNKNOWN.egg-info/top_level.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
__init__
db_folder
helpers
jachym
text_json
ui
8 changes: 7 additions & 1 deletion src/db_folder/databases.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ async def fetch_all_polls(self, bot: "Jachym") -> AsyncIterator[Poll and Message
sql = "SELECT * FROM `Poll`"
polls = await self.fetch_all_values(sql)

for message_id, channel_id, question, date, _ in polls:
for message_id, channel_id, question, date, user_id in polls:
try:
message = await bot.get_partial_messageable(channel_id).fetch_message(
message_id,
Expand All @@ -94,6 +94,7 @@ async def fetch_all_polls(self, bot: "Jachym") -> AsyncIterator[Poll and Message
question=question,
date_created=date,
options=options,
user_id=user_id,
)

yield pool, message
Expand All @@ -108,6 +109,11 @@ async def add_options(self, discord_poll: Poll):
values = [(discord_poll.message_id, vote_option) for vote_option in discord_poll.options]
await self.commit_many_values(sql, values)

async def add_option(self, discord_poll: Poll, option: str):
sql = "INSERT INTO `VoteButtons`(message_id, answers) VALUES (%s, %s)"
value = discord_poll.message_id, option
await self.commit_value(sql, value)

async def add_user(self, discord_poll: Poll, user: int, index: int):
sql = "INSERT INTO `Answers`(message_id, vote_user, iter_index) VALUES (%s, %s, %s)"
values = (discord_poll.message_id, user, index)
Expand Down
4 changes: 2 additions & 2 deletions src/jachym.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ class Jachym(commands.Bot):
def __init__(self) -> None:
# https://discordpy.readthedocs.io/en/stable/intents.html
self.pool: aiomysql.pool.Pool | None = None
self.active_discord_polls: set[Poll] = set()
self.active_discord_polls: set[tuple[Poll, discord.Message]] = set()

super().__init__(
command_prefix=commands.when_mentioned_or("!"),
Expand All @@ -43,7 +43,7 @@ async def _fetch_pools_from_database(self) -> None:
self.add_view(
PollView(poll=poll, embed=message.embeds[0], db_poll=self.pool),
)
self.active_discord_polls.add(poll)
self.active_discord_polls.add((poll, message))

logger.success(f"There are now {len(self.active_discord_polls)} active pools!")

Expand Down
64 changes: 56 additions & 8 deletions src/ui/button.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import asyncio

import aiomysql.pool
import discord
from discord import InteractionResponse, Member

from src.db_folder.databases import VoteButtonDatabase
from src.ui.embeds import PollEmbed
from src.ui.emojis import ScoutEmojis
from src.ui.modals import NewOptionModal
from src.ui.poll import Poll


Expand Down Expand Up @@ -37,32 +42,75 @@ def __init__(
def index(self):
return self._index

async def toggle_vote(self, interaction: discord.Interaction) -> set[str]:
async def toggle_vote(self, interaction: discord.Interaction) -> set[Member]:
vote_button_db = VoteButtonDatabase(self.db_poll)
user = interaction.user.id

users_id = await vote_button_db.fetch_all_users(self.poll, self.index)

if user not in users_id:
await vote_button_db.add_user(self.poll, user, self.index)
asyncio.create_task(vote_button_db.add_user(self.poll, user, self.index))
users_id.add(user)
else:
await vote_button_db.remove_user(self.poll, user, self.index)
asyncio.create_task(vote_button_db.remove_user(self.poll, user, self.index))
users_id.remove(user)

return {interaction.guild.get_member(user_id).display_name for user_id in users_id}
return {interaction.guild.get_member(user_id) for user_id in users_id}

async def edit_embed(self, members: set[str]) -> discord.Embed:
async def edit_embed(self, members: set[Member]) -> discord.Embed:
return self.embed.set_field_at(
index=self.index,
name=self.embed.fields[self.index].name,
value=f"**{len(members)}** | {', '.join(members)}",
value=f"**{len(members)}** | {', '.join(member.mention for member in members)}",
inline=False,
)

async def callback(self, interaction: discord.Interaction):
async def callback(self, interaction: discord.Interaction) -> InteractionResponse:
members = await self.toggle_vote(interaction)

edited_embed = await self.edit_embed(members)
return await interaction.response.edit_message(embed=edited_embed)


class NewOptionButton(discord.ui.Button):
LABEL = "Přidat novou možnost"

def __init__(self, embed: PollEmbed, poll: Poll, db_pool: aiomysql.pool.Pool):
self.embed = embed
self.poll = poll
self.db_pool = db_pool

super().__init__(
label=self.LABEL,
emoji=ScoutEmojis.FLEUR_DE_LIS.value,
custom_id=f"option_button::{poll.message_id}",
row=4,
)

async def callback(self, interaction: discord.Interaction) -> InteractionResponse:
await self.interaction_check(interaction)

modal = NewOptionModal(self.embed, self.db_pool, self.poll, self.view)
return await interaction.response.send_modal(modal)

async def interaction_check(self, interaction: discord.Interaction) -> PermissionError | ValueError | None:
"""
This function does error handling for pressing the button, before anything shows. Unfortunately it can't
use PrettyError() class, because components derived from Item() class has errors silently passed. This means
that we should use errors derived from Exception() class instead.
Parameters
----------
interaction: discord.Interaction
await interaction.response.edit_message(embed=edited_embed)
Raises
-------
PermissionError, ValueError
"""
if self.poll.user_id != interaction.user.id:
msg = "Nejsi uživatel, kdo vytvořil tuto anketu. Nemáš tedy nárok ji upravovat."
raise PermissionError(msg)
if len(self.embed.fields) >= 10:
msg = "Nemůžeš mít víc jak 10 možností!"
raise ValueError(msg)
return None
Loading

0 comments on commit 2c5cbea

Please sign in to comment.