From e379e79a0e142270a72ce357010ebd2952b2a7af Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Fri, 12 Jun 2026 15:00:18 -0600 Subject: [PATCH 1/3] Refactor to reduce docker image size --- .vscode/settings.json | 11 + README.md | 7 +- {ui => src}/__init__.py | 0 bot.py => src/bot.py | 488 ++++++++++----------- db.py => src/db.py | 510 +++++++++++----------- help_queue.py => src/help_queue.py | 158 +++---- records.py => src/records.py | 0 {resources => src/resources}/droid.mp3 | Bin {resources => src/resources}/select.mp3 | Bin {resources => src/resources}/surprise.mp3 | Bin {ui/helpers => src/ui}/__init__.py | 0 {ui/views => src/ui/helpers}/__init__.py | 0 {ui => src/ui}/helpers/constants.py | 0 {ui => src/ui}/helpers/discord_helpers.py | 0 {ui => src/ui}/helpers/queue_helpers.py | 0 {ui => src/ui}/helpers/utils.py | 0 {ui => src/ui}/modals.py | 0 src/ui/views/__init__.py | 0 {ui => src/ui}/views/queue_view.py | 2 +- {ui => src/ui}/views/ta_view.py | 0 tests/__init__.py | 0 tests/test_remove_student.py | 93 ++-- tests/test_setup.py | 8 + 23 files changed, 659 insertions(+), 618 deletions(-) create mode 100644 .vscode/settings.json rename {ui => src}/__init__.py (100%) rename bot.py => src/bot.py (97%) rename db.py => src/db.py (95%) rename help_queue.py => src/help_queue.py (97%) rename records.py => src/records.py (100%) rename {resources => src/resources}/droid.mp3 (100%) rename {resources => src/resources}/select.mp3 (100%) rename {resources => src/resources}/surprise.mp3 (100%) rename {ui/helpers => src/ui}/__init__.py (100%) rename {ui/views => src/ui/helpers}/__init__.py (100%) rename {ui => src/ui}/helpers/constants.py (100%) rename {ui => src/ui}/helpers/discord_helpers.py (100%) rename {ui => src/ui}/helpers/queue_helpers.py (100%) rename {ui => src/ui}/helpers/utils.py (100%) rename {ui => src/ui}/modals.py (100%) create mode 100644 src/ui/views/__init__.py rename {ui => src/ui}/views/queue_view.py (100%) rename {ui => src/ui}/views/ta_view.py (100%) create mode 100644 tests/__init__.py create mode 100644 tests/test_setup.py diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b1506db --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "./tests", + "-p", + "test*.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true +} \ No newline at end of file diff --git a/README.md b/README.md index cfc47de..78b56c9 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,10 @@ TOKEN=your-discord-bot-token-here #### 1. Ensure your `.env` file contains the bot token. #### 2. If you want voice alerts, place one or more `.mp3` files in the `resources/` folder. -#### 3. Start the bot from the project root in the virtual environment: +#### 3. Start the bot from the src directory in the virtual environment: ```powershell +cd src python bot.py ``` @@ -130,3 +131,7 @@ python bot.py - If the bot cannot find the `resources/` folder or no MP3 files are present, it will still run but will not play audio. - You can customize channel names and messages in `ui/helpers/constants.py`. +- To run tests, navigate to the project directory and run the following in the terminal: +```powershell +python -m unittest discover -s tests +``` \ No newline at end of file diff --git a/ui/__init__.py b/src/__init__.py similarity index 100% rename from ui/__init__.py rename to src/__init__.py diff --git a/bot.py b/src/bot.py similarity index 97% rename from bot.py rename to src/bot.py index 8ce2d40..bd6a883 100644 --- a/bot.py +++ b/src/bot.py @@ -1,245 +1,245 @@ -import discord -from discord import app_commands -from discord.utils import get -from help_queue import HelpQueue -from ui.views.queue_view import QueueView -from ui.views.ta_view import TAView -from ui.helpers.constants import HELP_CHANNEL_NAME, TA_TEXT_CHANNEL_NAME, TA_VOICE_CHANNEL_NAME -from ui.helpers.discord_helpers import update_queue_messages -from records import QueueEntry -from datetime import datetime -from db import daily_reset, auto_queue_scheduler - -import os -import random -import asyncio -from typing import Optional -from dotenv import load_dotenv - -load_dotenv(".env") - -intents = discord.Intents.default() -intents.message_content = True - -class Bot(discord.Client): - """ - The core bot client that manages the help queue, UI views, and scheduled tasks. - Extends discord.Client to handle queue interactions and audio notifications. - """ - def __init__(self): - super().__init__(intents=intents) - self.tree = app_commands.CommandTree(self) - self.queue: HelpQueue = HelpQueue() - self.queue_status_message_id: int | None = None - self.help_queue_count_message_id: int | None = None - self._player_task: Optional[asyncio.Task] = None - - async def setup_hook(self): - """ - Initializes bot UI components (views) and starts background scheduling tasks - before the bot fully connects to Discord. - """ - # guild = self.get_guild(1503856452027023451) - # print(guild.name) - # self.tree.copy_global_to(guild=guild) - self.add_view(QueueView()) - self.add_view(TAView()) - daily_reset.start() - auto_queue_scheduler.start(self) - await self.tree.sync() - - async def on_ready(self): - await update_queue_messages(self) - # ensure player task isn't left running accidentally - if self._player_task is None and getattr(self.queue, 'entries', None): - # start player only if queue not empty - if len(self.queue.entries) > 0: - self._player_task = asyncio.create_task(self._play_notifications()) - - async def _get_ta_voice_channel(self) -> discord.VoiceChannel | None: - for guild in self.guilds: - return get(guild.voice_channels, name=TA_VOICE_CHANNEL_NAME) - - - async def _play_notifications(self) -> None: - """Join TA voice channel and play random mp3 from resources once per minute until queue empty.""" - try: - resources_dir = os.path.join(os.path.dirname(__file__), "resources") - except Exception: - resources_dir = None - - while True: - async with self.queue.lock: - empty = len(self.queue.entries) == 0 - if empty: - break - - channel = await self._get_ta_voice_channel() - if channel is None: - await asyncio.sleep(60) - continue - - try: - # connect if not connected - vc = channel.guild.voice_client - if vc is None: - vc = await channel.connect() - print("Theoretically I should've connected to the channel") - - # choose random mp3 - chosen = None - if resources_dir and os.path.isdir(resources_dir): - files = [f for f in os.listdir(resources_dir) if f.lower().endswith('.mp3')] - if files: - chosen = os.path.join(resources_dir, random.choice(files)) - - if chosen: - if vc.is_playing(): - vc.stop() - source = discord.FFmpegPCMAudio(chosen) - vc.play(source) - print("sound played") - # wait until finished or 60s - waited = 0 - while vc.is_playing() and waited < 120: - await asyncio.sleep(1) - waited += 1 - - # wait one minute between plays - await asyncio.sleep(60) - except Exception as e: - print(e.with_traceback()) - await asyncio.sleep(60) - - # queue empty, disconnect - try: - for guild in self.guilds: - if guild.voice_client: - await guild.voice_client.disconnect() - except Exception: - pass - - self._player_task = None - - async def _get_ta_channel(self) -> discord.TextChannel | None: - for guild in self.guilds: - return get(guild.text_channels, name=TA_TEXT_CHANNEL_NAME) - return None - - async def _get_help_channel(self) -> discord.TextChannel | None: - for guild in self.guilds: - return get(guild.text_channels, name=HELP_CHANNEL_NAME) - return None - - async def _build_queue_status(self) -> str: - status = "OPEN" if self.queue.is_open else "CLOSED" - queue_text = await self.queue.view() - return f"**Help Queue Status: {status}**\n{queue_text}" - - async def _get_status_message(self) -> discord.Message | None: - ta_channel = await self._get_ta_channel() - if ta_channel is None: - return None - - if self.queue_status_message_id is not None: - try: - return await ta_channel.fetch_message(self.queue_status_message_id) - except discord.NotFound: - self.queue_status_message_id = None - - async for message in ta_channel.history(limit=50): - if message.author == self.user and message.content.startswith("**Help Queue Status:"): - self.queue_status_message_id = message.id - return message - - status_message = await ta_channel.send(await self._build_queue_status()) - self.queue_status_message_id = status_message.id - return status_message - - async def update_status_for_students(self) -> None: - status_message = await self._get_status_message() - if status_message is None: - return - - await status_message.edit(content=await self._build_queue_status()) - - async def _build_help_queue_count(self) -> str: - status = "OPEN" if self.queue.is_open else "CLOSED" - async with self.queue.lock: - count = len(self.queue.entries) - return f"**Help Queue Status: {status} β€” {count} student{'s' if count != 1 else ''} in queue**" - - async def _get_count_message(self) -> discord.Message | None: - help_channel = await self._get_help_channel() - if help_channel is None: - return None - - if self.help_queue_count_message_id is not None: - try: - return await help_channel.fetch_message(self.help_queue_count_message_id) - except discord.NotFound: - self.help_queue_count_message_id = None - - async for message in help_channel.history(limit=50): - if message.author == self.user and message.content.startswith("**Help Queue Status:"): - self.help_queue_count_message_id = message.id - return message - - count_message = await help_channel.send(await self._build_help_queue_count()) - self.help_queue_count_message_id = count_message.id - return count_message - - async def update_status_for_tas(self) -> None: - count_message = await self._get_count_message() - if count_message is None: - return - - await count_message.edit(content=await self._build_help_queue_count()) - - async def queue_handler(self, interaction: discord.Interaction, question, is_passoff, in_person, student_name: str): - """ - Processes a new request to join the help queue, creates a QueueEntry, - updates the UI, and triggers the audio notification system if needed. - - Args: - interaction (discord.Interaction): The user interaction context. - question (str): The student's question or issue details. - is_passoff (bool): Indicates if this is a required pass-off assignment. - in_person (bool): Indicates if the student is physically present. - student_name (str): The student's actual name. - """ - entry = QueueEntry( - user_id=interaction.user.id, - username=interaction.user.display_name, - student_name=student_name, - details=question, - is_passoff=is_passoff, - timestamp=datetime.now(), - in_person=in_person - ) - - await self.queue.add(entry) - await update_queue_messages(self) - # start playing notifications while queue has entries - async with self.queue.lock: - had = len(self.queue.entries) > 0 - if had and (self._player_task is None or self._player_task.done()): - self._player_task = asyncio.create_task(self._play_notifications()) - - -bot = Bot() - -@bot.tree.command(name="queue") -async def queue_panel(interaction: discord.Interaction): - await interaction.response.send_message( - view=QueueView() - ) - -@bot.tree.command(name="ta") -async def ta_panel(interaction: discord.Interaction): - await interaction.response.send_message( - view=TAView() - ) - -token: str = os.getenv("TOKEN") +import discord +from discord import app_commands +from discord.utils import get +from help_queue import HelpQueue +from ui.views.queue_view import QueueView +from ui.views.ta_view import TAView +from ui.helpers.constants import HELP_CHANNEL_NAME, TA_TEXT_CHANNEL_NAME, TA_VOICE_CHANNEL_NAME +from ui.helpers.discord_helpers import update_queue_messages +from records import QueueEntry +from datetime import datetime +from db import daily_reset, auto_queue_scheduler + +import os +import random +import asyncio +from typing import Optional +from dotenv import load_dotenv + +load_dotenv("./resources/.env") + +intents = discord.Intents.default() +intents.message_content = True + +class Bot(discord.Client): + """ + The core bot client that manages the help queue, UI views, and scheduled tasks. + Extends discord.Client to handle queue interactions and audio notifications. + """ + def __init__(self): + super().__init__(intents=intents) + self.tree = app_commands.CommandTree(self) + self.queue: HelpQueue = HelpQueue() + self.queue_status_message_id: int | None = None + self.help_queue_count_message_id: int | None = None + self._player_task: Optional[asyncio.Task] = None + + async def setup_hook(self): + """ + Initializes bot UI components (views) and starts background scheduling tasks + before the bot fully connects to Discord. + """ + # guild = self.get_guild(1503856452027023451) + # print(guild.name) + # self.tree.copy_global_to(guild=guild) + self.add_view(QueueView()) + self.add_view(TAView()) + daily_reset.start() + auto_queue_scheduler.start(self) + await self.tree.sync() + + async def on_ready(self): + await update_queue_messages(self) + # ensure player task isn't left running accidentally + if self._player_task is None and getattr(self.queue, 'entries', None): + # start player only if queue not empty + if len(self.queue.entries) > 0: + self._player_task = asyncio.create_task(self._play_notifications()) + + async def _get_ta_voice_channel(self) -> discord.VoiceChannel | None: + for guild in self.guilds: + return get(guild.voice_channels, name=TA_VOICE_CHANNEL_NAME) + + + async def _play_notifications(self) -> None: + """Join TA voice channel and play random mp3 from resources once per minute until queue empty.""" + try: + resources_dir = os.path.join(os.path.dirname(__file__), "resources") + except Exception: + resources_dir = None + + while True: + async with self.queue.lock: + empty = len(self.queue.entries) == 0 + if empty: + break + + channel = await self._get_ta_voice_channel() + if channel is None: + await asyncio.sleep(60) + continue + + try: + # connect if not connected + vc = channel.guild.voice_client + if vc is None: + vc = await channel.connect() + print("Theoretically I should've connected to the channel") + + # choose random mp3 + chosen = None + if resources_dir and os.path.isdir(resources_dir): + files = [f for f in os.listdir(resources_dir) if f.lower().endswith('.mp3')] + if files: + chosen = os.path.join(resources_dir, random.choice(files)) + + if chosen: + if vc.is_playing(): + vc.stop() + source = discord.FFmpegPCMAudio(chosen) + vc.play(source) + print("sound played") + # wait until finished or 60s + waited = 0 + while vc.is_playing() and waited < 120: + await asyncio.sleep(1) + waited += 1 + + # wait one minute between plays + await asyncio.sleep(60) + except Exception as e: + print(e.with_traceback()) + await asyncio.sleep(60) + + # queue empty, disconnect + try: + for guild in self.guilds: + if guild.voice_client: + await guild.voice_client.disconnect() + except Exception: + pass + + self._player_task = None + + async def _get_ta_channel(self) -> discord.TextChannel | None: + for guild in self.guilds: + return get(guild.text_channels, name=TA_TEXT_CHANNEL_NAME) + return None + + async def _get_help_channel(self) -> discord.TextChannel | None: + for guild in self.guilds: + return get(guild.text_channels, name=HELP_CHANNEL_NAME) + return None + + async def _build_queue_status(self) -> str: + status = "OPEN" if self.queue.is_open else "CLOSED" + queue_text = await self.queue.view() + return f"**Help Queue Status: {status}**\n{queue_text}" + + async def _get_status_message(self) -> discord.Message | None: + ta_channel = await self._get_ta_channel() + if ta_channel is None: + return None + + if self.queue_status_message_id is not None: + try: + return await ta_channel.fetch_message(self.queue_status_message_id) + except discord.NotFound: + self.queue_status_message_id = None + + async for message in ta_channel.history(limit=50): + if message.author == self.user and message.content.startswith("**Help Queue Status:"): + self.queue_status_message_id = message.id + return message + + status_message = await ta_channel.send(await self._build_queue_status()) + self.queue_status_message_id = status_message.id + return status_message + + async def update_status_for_students(self) -> None: + status_message = await self._get_status_message() + if status_message is None: + return + + await status_message.edit(content=await self._build_queue_status()) + + async def _build_help_queue_count(self) -> str: + status = "OPEN" if self.queue.is_open else "CLOSED" + async with self.queue.lock: + count = len(self.queue.entries) + return f"**Help Queue Status: {status} β€” {count} student{'s' if count != 1 else ''} in queue**" + + async def _get_count_message(self) -> discord.Message | None: + help_channel = await self._get_help_channel() + if help_channel is None: + return None + + if self.help_queue_count_message_id is not None: + try: + return await help_channel.fetch_message(self.help_queue_count_message_id) + except discord.NotFound: + self.help_queue_count_message_id = None + + async for message in help_channel.history(limit=50): + if message.author == self.user and message.content.startswith("**Help Queue Status:"): + self.help_queue_count_message_id = message.id + return message + + count_message = await help_channel.send(await self._build_help_queue_count()) + self.help_queue_count_message_id = count_message.id + return count_message + + async def update_status_for_tas(self) -> None: + count_message = await self._get_count_message() + if count_message is None: + return + + await count_message.edit(content=await self._build_help_queue_count()) + + async def queue_handler(self, interaction: discord.Interaction, question, is_passoff, in_person, student_name: str): + """ + Processes a new request to join the help queue, creates a QueueEntry, + updates the UI, and triggers the audio notification system if needed. + + Args: + interaction (discord.Interaction): The user interaction context. + question (str): The student's question or issue details. + is_passoff (bool): Indicates if this is a required pass-off assignment. + in_person (bool): Indicates if the student is physically present. + student_name (str): The student's actual name. + """ + entry = QueueEntry( + user_id=interaction.user.id, + username=interaction.user.display_name, + student_name=student_name, + details=question, + is_passoff=is_passoff, + timestamp=datetime.now(), + in_person=in_person + ) + + await self.queue.add(entry) + await update_queue_messages(self) + # start playing notifications while queue has entries + async with self.queue.lock: + had = len(self.queue.entries) > 0 + if had and (self._player_task is None or self._player_task.done()): + self._player_task = asyncio.create_task(self._play_notifications()) + + +bot = Bot() + +@bot.tree.command(name="queue") +async def queue_panel(interaction: discord.Interaction): + await interaction.response.send_message( + view=QueueView() + ) + +@bot.tree.command(name="ta") +async def ta_panel(interaction: discord.Interaction): + await interaction.response.send_message( + view=TAView() + ) + +token: str = os.getenv("TOKEN") bot.run(token) \ No newline at end of file diff --git a/db.py b/src/db.py similarity index 95% rename from db.py rename to src/db.py index 88a93ba..34d8fdd 100644 --- a/db.py +++ b/src/db.py @@ -1,255 +1,255 @@ -import sqlite3 -import discord -from datetime import date, datetime, time -from typing import List, Optional -from zoneinfo import ZoneInfo -from discord.ext import tasks -from ui.helpers.discord_helpers import update_queue_messages - -conn: sqlite3.Connection = sqlite3.connect("queue.db", detect_types=sqlite3.PARSE_DECLTYPES) -conn.row_factory = sqlite3.Row - - -def _initialize_database() -> None: - with conn: - conn.execute( - """ - CREATE TABLE IF NOT EXISTS user_stats ( - user_id INTEGER PRIMARY KEY, - user_name STRING, - student_name STRING, - total_help INTEGER DEFAULT 0, - daily_help INTEGER DEFAULT 0, - last_reset TEXT - ) - """ - ) - - conn.execute( - """ - CREATE TABLE IF NOT EXISTS bot_incidents ( - id INTEGER PRIMARY KEY, - last_incident TEXT, - last_issue TEXT - ) - """ - ) - - conn.execute( - """ - CREATE TABLE IF NOT EXISTS queue_settings ( - id INTEGER PRIMARY KEY, - open_hour INTEGER DEFAULT 8, - open_minute INTEGER DEFAULT 0, - close_hour INTEGER DEFAULT 20, - close_minute INTEGER DEFAULT 0 - ) - """ - ) - - - # dequeue_time refers to the time the TA begins helping the student, as the student is no longer waiting in the queue - conn.execute( - """ - CREATE TABLE IF NOT EXISTS queue_history ( - id INTEGER PRIMARY KEY, - student_name TEXT NOT NULL, - TA_name TEXT NOT NULL, - question TEXT, - enqueue_time TEXT NOT NULL, - dequeue_time TEXT NOT NULL, - is_passoff INTEGER CHECK (is_passof IN (0,1)), - in_person INTEGER CHECK (in_person IN (0,1)), - time_finished TEXT - ) - """ - ) - - # Ensure queue_settings has a default row - cursor = conn.cursor() - cursor.execute("SELECT COUNT(*) FROM queue_settings") - if cursor.fetchone()[0] == 0: - conn.execute("INSERT INTO queue_settings (id, open_hour, open_minute, close_hour, close_minute) VALUES (1, 8, 0, 20, 0)") - - -_initialize_database() - -@tasks.loop( - time=time( - hour=23, - minute=59, - tzinfo=ZoneInfo("America/Denver") - ) -) -async def daily_reset() -> None: - cursor = conn.cursor() - cursor.execute( - "UPDATE user_stats SET daily_help = 0" - ) - conn.commit() - - print("Daily help counts reset.") - - -def increment_help(user_id: int, user_name: str, student_name: Optional[str] = None) -> None: - """ - Records a help session for a user, incrementing both their total and daily help counts. - Creates a new user record if one does not exist. - - Args: - user_id (int): The Discord user ID. - user_name (str): The user's Discord username. - student_name (Optional[str]): The student's actual name, if provided. - """ - cursor = conn.cursor() - cursor.execute( - """ - INSERT INTO user_stats (user_id, user_name, student_name, total_help, daily_help, last_reset) - VALUES (?, ?, ?, 1, 1, ?) - ON CONFLICT(user_id) DO UPDATE SET - user_name = ?, - total_help = total_help + 1, - daily_help = daily_help + 1 - """, - (user_id, user_name, student_name or "", str(date.today()), user_name), - ) - - if student_name: - _update_student_name(user_id, student_name) - - conn.commit() - - -def _update_student_name(user_id: int, student_name: str) -> None: - """ - Updates the student's name in the database if the new name provided is longer - than the currently stored name. - - Args: - user_id (int): The Discord user ID of the student. - student_name (str): The new name to evaluate and potentially store. - """ - cursor = conn.cursor() - cursor.execute("SELECT student_name FROM user_stats WHERE user_id=?", (user_id,)) - row = cursor.fetchone() - - existing_name = row[0] if row else "" - if len(student_name) > len(existing_name or ""): - cursor.execute( - "UPDATE user_stats SET student_name = ? WHERE user_id = ?", - (student_name, user_id), - ) - conn.commit() - -def record_bot_issue(timestamp: datetime, issue: str) -> None: - cursor = conn.cursor() - cursor.execute( - "INSERT INTO bot_incidents (id, last_incident, last_issue) VALUES (1, ?, ?)" - " ON CONFLICT(id) DO UPDATE SET last_incident = ?, last_issue = ?", - (timestamp.isoformat(), issue, timestamp.isoformat(), issue), - ) - conn.commit() - - -def get_last_incident_info() -> tuple[Optional[int], Optional[str]]: - cursor = conn.cursor() - cursor.execute("SELECT last_incident, last_issue FROM bot_incidents WHERE id = 1") - row = cursor.fetchone() - if not row or not row[0]: - return None, None - - try: - last_incident = datetime.fromisoformat(row[0]) - except ValueError: - return None, row[1] or None - - delta = datetime.now() - last_incident - return delta.days, row[1] or None - - -def get_student_info() -> tuple[List[str], List[tuple[str, int, int]]]: - """Get information about students that have joined the help queue. - - Returns: - A tuple containing 2 lists. - The first list contains strings indicating what information corresponds to which index of the student's information. - The second list contains a list for each student in the database. - - _**Example:**_ There are two users in the database, and the first list contains the strings "username" and "times helped today" - The resulting tuple would look like the following:\n - `( ["username", "times helped today"],`\n `[ ["Freddy", 1],`\n `["Howie", 3] ] )`\n - In practice more information is given by this function. - """ - - result: tuple[List[str], List[tuple[str, int, int]]] = ( - ["Name", "Total Help Queue visits", "Times Helped Today"], - [], - ) - - cursor = conn.cursor() - for row in cursor.execute("SELECT COALESCE(NULLIF(student_name, ''), user_name), total_help, daily_help FROM user_stats"): - result[1].append((row[0], row[1], row[2])) - - return result - -def get_times_helped_today(user_id: int) -> int: - cursor = conn.cursor() - cursor.execute("SELECT daily_help FROM user_stats WHERE user_id=?", (user_id,)) - row = cursor.fetchone() - return int(row[0]) if row else 0 - - -def get_queue_times() -> tuple[int, int, int, int]: - """Get the configured queue open and close times. - - Returns: - Tuple of (open_hour, open_minute, close_hour, close_minute) - """ - cursor = conn.cursor() - cursor.execute("SELECT open_hour, open_minute, close_hour, close_minute FROM queue_settings WHERE id = 1") - row = cursor.fetchone() - if row: - return int(row[0]), int(row[1]), int(row[2]), int(row[3]) - return 8, 0, 20, 0 # Default to 8:00am-8:00pm - - -def set_queue_times(open_hour: int, open_minute: int, close_hour: int, close_minute: int) -> None: - """Set the queue open and close times. - - Args: - open_hour: Hour to open (0-23) - open_minute: Minute to open (0-59) - close_hour: Hour to close (0-23) - close_minute: Minute to close (0-59) - """ - if not (0 <= open_hour <= 23 and 0 <= open_minute <= 59 and 0 <= close_hour <= 23 and 0 <= close_minute <= 59): - raise ValueError("Hours must be 0-23 and minutes must be 0-59") - cursor = conn.cursor() - cursor.execute( - "UPDATE queue_settings SET open_hour = ?, open_minute = ?, close_hour = ?, close_minute = ? WHERE id = 1", - (open_hour, open_minute, close_hour, close_minute) - ) - conn.commit() - - - -# Queue auto-open/close scheduled tasks -@tasks.loop(minutes=1) -async def auto_queue_scheduler(bot_client: discord.Client) -> None: - """Check if queue should be auto-opened or auto-closed every minute.""" - open_hour, open_minute, close_hour, close_minute = get_queue_times() - denver_tz = ZoneInfo("America/Denver") - current_time = datetime.now(denver_tz) - - # Check if we should open (at the configured open time) - if current_time.hour == open_hour and current_time.minute == open_minute and not bot_client.queue.is_open: - bot_client.queue.is_open = True - print(f"Queue auto-opened at {current_time.strftime('%H:%M')}") - await update_queue_messages(bot_client) - - - # Check if we should close (at the configured close time) - elif current_time.hour == close_hour and current_time.minute == close_minute and bot_client.queue.is_open: - bot_client.queue.is_open = False - print(f"Queue auto-closed at {current_time.strftime('%H:%M')}") - await update_queue_messages(bot_client) +import sqlite3 +import discord +from datetime import date, datetime, time +from typing import List, Optional +from zoneinfo import ZoneInfo +from discord.ext import tasks +from ui.helpers.discord_helpers import update_queue_messages + +conn: sqlite3.Connection = sqlite3.connect("./resources/queue.db", detect_types=sqlite3.PARSE_DECLTYPES) +conn.row_factory = sqlite3.Row + + +def _initialize_database() -> None: + with conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS user_stats ( + user_id INTEGER PRIMARY KEY, + user_name STRING, + student_name STRING, + total_help INTEGER DEFAULT 0, + daily_help INTEGER DEFAULT 0, + last_reset TEXT + ) + """ + ) + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS bot_incidents ( + id INTEGER PRIMARY KEY, + last_incident TEXT, + last_issue TEXT + ) + """ + ) + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS queue_settings ( + id INTEGER PRIMARY KEY, + open_hour INTEGER DEFAULT 8, + open_minute INTEGER DEFAULT 0, + close_hour INTEGER DEFAULT 20, + close_minute INTEGER DEFAULT 0 + ) + """ + ) + + + # dequeue_time refers to the time the TA begins helping the student, as the student is no longer waiting in the queue + conn.execute( + """ + CREATE TABLE IF NOT EXISTS queue_history ( + id INTEGER PRIMARY KEY, + student_name TEXT NOT NULL, + TA_name TEXT NOT NULL, + question TEXT, + enqueue_time TEXT NOT NULL, + dequeue_time TEXT NOT NULL, + is_passoff INTEGER CHECK (is_passoff IN (0,1)), + in_person INTEGER CHECK (in_person IN (0,1)), + time_finished TEXT + ) + """ + ) + + # Ensure queue_settings has a default row + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM queue_settings") + if cursor.fetchone()[0] == 0: + conn.execute("INSERT INTO queue_settings (id, open_hour, open_minute, close_hour, close_minute) VALUES (1, 8, 0, 20, 0)") + + +_initialize_database() + +@tasks.loop( + time=time( + hour=23, + minute=59, + tzinfo=ZoneInfo("America/Denver") + ) +) +async def daily_reset() -> None: + cursor = conn.cursor() + cursor.execute( + "UPDATE user_stats SET daily_help = 0" + ) + conn.commit() + + print("Daily help counts reset.") + + +def increment_help(user_id: int, user_name: str, student_name: Optional[str] = None) -> None: + """ + Records a help session for a user, incrementing both their total and daily help counts. + Creates a new user record if one does not exist. + + Args: + user_id (int): The Discord user ID. + user_name (str): The user's Discord username. + student_name (Optional[str]): The student's actual name, if provided. + """ + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO user_stats (user_id, user_name, student_name, total_help, daily_help, last_reset) + VALUES (?, ?, ?, 1, 1, ?) + ON CONFLICT(user_id) DO UPDATE SET + user_name = ?, + total_help = total_help + 1, + daily_help = daily_help + 1 + """, + (user_id, user_name, student_name or "", str(date.today()), user_name), + ) + + if student_name: + _update_student_name(user_id, student_name) + + conn.commit() + + +def _update_student_name(user_id: int, student_name: str) -> None: + """ + Updates the student's name in the database if the new name provided is longer + than the currently stored name. + + Args: + user_id (int): The Discord user ID of the student. + student_name (str): The new name to evaluate and potentially store. + """ + cursor = conn.cursor() + cursor.execute("SELECT student_name FROM user_stats WHERE user_id=?", (user_id,)) + row = cursor.fetchone() + + existing_name = row[0] if row else "" + if len(student_name) > len(existing_name or ""): + cursor.execute( + "UPDATE user_stats SET student_name = ? WHERE user_id = ?", + (student_name, user_id), + ) + conn.commit() + +def record_bot_issue(timestamp: datetime, issue: str) -> None: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO bot_incidents (id, last_incident, last_issue) VALUES (1, ?, ?)" + " ON CONFLICT(id) DO UPDATE SET last_incident = ?, last_issue = ?", + (timestamp.isoformat(), issue, timestamp.isoformat(), issue), + ) + conn.commit() + + +def get_last_incident_info() -> tuple[Optional[int], Optional[str]]: + cursor = conn.cursor() + cursor.execute("SELECT last_incident, last_issue FROM bot_incidents WHERE id = 1") + row = cursor.fetchone() + if not row or not row[0]: + return None, None + + try: + last_incident = datetime.fromisoformat(row[0]) + except ValueError: + return None, row[1] or None + + delta = datetime.now() - last_incident + return delta.days, row[1] or None + + +def get_student_info() -> tuple[List[str], List[tuple[str, int, int]]]: + """Get information about students that have joined the help queue. + + Returns: + A tuple containing 2 lists. + The first list contains strings indicating what information corresponds to which index of the student's information. + The second list contains a list for each student in the database. + + _**Example:**_ There are two users in the database, and the first list contains the strings "username" and "times helped today" + The resulting tuple would look like the following:\n + `( ["username", "times helped today"],`\n `[ ["Freddy", 1],`\n `["Howie", 3] ] )`\n + In practice more information is given by this function. + """ + + result: tuple[List[str], List[tuple[str, int, int]]] = ( + ["Name", "Total Help Queue visits", "Times Helped Today"], + [], + ) + + cursor = conn.cursor() + for row in cursor.execute("SELECT COALESCE(NULLIF(student_name, ''), user_name), total_help, daily_help FROM user_stats"): + result[1].append((row[0], row[1], row[2])) + + return result + +def get_times_helped_today(user_id: int) -> int: + cursor = conn.cursor() + cursor.execute("SELECT daily_help FROM user_stats WHERE user_id=?", (user_id,)) + row = cursor.fetchone() + return int(row[0]) if row else 0 + + +def get_queue_times() -> tuple[int, int, int, int]: + """Get the configured queue open and close times. + + Returns: + Tuple of (open_hour, open_minute, close_hour, close_minute) + """ + cursor = conn.cursor() + cursor.execute("SELECT open_hour, open_minute, close_hour, close_minute FROM queue_settings WHERE id = 1") + row = cursor.fetchone() + if row: + return int(row[0]), int(row[1]), int(row[2]), int(row[3]) + return 8, 0, 20, 0 # Default to 8:00am-8:00pm + + +def set_queue_times(open_hour: int, open_minute: int, close_hour: int, close_minute: int) -> None: + """Set the queue open and close times. + + Args: + open_hour: Hour to open (0-23) + open_minute: Minute to open (0-59) + close_hour: Hour to close (0-23) + close_minute: Minute to close (0-59) + """ + if not (0 <= open_hour <= 23 and 0 <= open_minute <= 59 and 0 <= close_hour <= 23 and 0 <= close_minute <= 59): + raise ValueError("Hours must be 0-23 and minutes must be 0-59") + cursor = conn.cursor() + cursor.execute( + "UPDATE queue_settings SET open_hour = ?, open_minute = ?, close_hour = ?, close_minute = ? WHERE id = 1", + (open_hour, open_minute, close_hour, close_minute) + ) + conn.commit() + + + +# Queue auto-open/close scheduled tasks +@tasks.loop(minutes=1) +async def auto_queue_scheduler(bot_client: discord.Client) -> None: + """Check if queue should be auto-opened or auto-closed every minute.""" + open_hour, open_minute, close_hour, close_minute = get_queue_times() + denver_tz = ZoneInfo("America/Denver") + current_time = datetime.now(denver_tz) + + # Check if we should open (at the configured open time) + if current_time.hour == open_hour and current_time.minute == open_minute and not bot_client.queue.is_open: + bot_client.queue.is_open = True + print(f"Queue auto-opened at {current_time.strftime('%H:%M')}") + await update_queue_messages(bot_client) + + + # Check if we should close (at the configured close time) + elif current_time.hour == close_hour and current_time.minute == close_minute and bot_client.queue.is_open: + bot_client.queue.is_open = False + print(f"Queue auto-closed at {current_time.strftime('%H:%M')}") + await update_queue_messages(bot_client) diff --git a/help_queue.py b/src/help_queue.py similarity index 97% rename from help_queue.py rename to src/help_queue.py index 1a32b2e..9c6f6c4 100644 --- a/help_queue.py +++ b/src/help_queue.py @@ -1,80 +1,80 @@ -import asyncio -from typing import Optional -from db import get_times_helped_today -from records import QueueEntry - -class HelpQueue: - def __init__(self): - self.entries: list[QueueEntry] = [] - self.lock = asyncio.Lock() - self.is_open = False - - async def add(self, entry: QueueEntry): - async with self.lock: - self.entries.append(entry) - - async def remove(self, user_id: int): - async with self.lock: - self.entries = [ - e for e in self.entries if e.user_id != user_id - ] - - async def get_position(self, user_id: int) -> Optional[int]: - async with self.lock: - for i, e in enumerate(self.entries): - if e.user_id == user_id: - return i + 1 - return None - - async def is_in_queue(self, user_id: int): - async with self.lock: - for entry in self.entries: - if entry.user_id == user_id: - return True - return False - - async def next(self, passoff_only=False, online_only=False) -> Optional[QueueEntry]: - async with self.lock: - if passoff_only: - for i, e in enumerate(self.entries): - if e.is_passoff: - if online_only and e.in_person: - return None - else: - return self.entries.pop(i) - - elif online_only: - for i, e in enumerate(self.entries): - if not e.in_person: - return self.entries.pop(i) - return None - else: - return self.entries.pop(0) if self.entries else None - - async def view(self) -> str: - async with self.lock: - if not self.entries: - return "Queue is empty." - - out = ["Students in queue:\n"] - for i, e in enumerate(self.entries, start=1): - p_tag = "PASSOFF" if e.is_passoff else "HELP" - o_tag = "ONLINE" if not e.in_person else "IN-PERSON" - times_helped = get_times_helped_today(e.user_id) - display_name = e.username - if e.student_name: - display_name = f"{e.username} ({e.student_name})" - out.append( - f"{i}. {display_name} - {p_tag} - {o_tag} - {e.details} " - f"(helped {times_helped} time{'s' if times_helped != 1 else ''} today)" - ) - - return "\n".join(out) - - async def get_front(self) -> Optional[QueueEntry]: - async with self.lock: - return self.entries[0] if self.entries else None - - async def clear(self): - async with self.lock: +import asyncio +from typing import Optional +from db import get_times_helped_today +from records import QueueEntry + +class HelpQueue: + def __init__(self): + self.entries: list[QueueEntry] = [] + self.lock = asyncio.Lock() + self.is_open = False + + async def add(self, entry: QueueEntry): + async with self.lock: + self.entries.append(entry) + + async def remove(self, user_id: int): + async with self.lock: + self.entries = [ + e for e in self.entries if e.user_id != user_id + ] + + async def get_position(self, user_id: int) -> Optional[int]: + async with self.lock: + for i, e in enumerate(self.entries): + if e.user_id == user_id: + return i + 1 + return None + + async def is_in_queue(self, user_id: int): + async with self.lock: + for entry in self.entries: + if entry.user_id == user_id: + return True + return False + + async def next(self, passoff_only=False, online_only=False) -> Optional[QueueEntry]: + async with self.lock: + if passoff_only: + for i, e in enumerate(self.entries): + if e.is_passoff: + if online_only and e.in_person: + return None + else: + return self.entries.pop(i) + + elif online_only: + for i, e in enumerate(self.entries): + if not e.in_person: + return self.entries.pop(i) + return None + else: + return self.entries.pop(0) if self.entries else None + + async def view(self) -> str: + async with self.lock: + if not self.entries: + return "Queue is empty." + + out = ["Students in queue:\n"] + for i, e in enumerate(self.entries, start=1): + p_tag = "PASSOFF" if e.is_passoff else "HELP" + o_tag = "ONLINE" if not e.in_person else "IN-PERSON" + times_helped = get_times_helped_today(e.user_id) + display_name = e.username + if e.student_name: + display_name = f"{e.username} ({e.student_name})" + out.append( + f"{i}. {display_name} - {p_tag} - {o_tag} - {e.details} " + f"(helped {times_helped} time{'s' if times_helped != 1 else ''} today)" + ) + + return "\n".join(out) + + async def get_front(self) -> Optional[QueueEntry]: + async with self.lock: + return self.entries[0] if self.entries else None + + async def clear(self): + async with self.lock: self.entries.clear() \ No newline at end of file diff --git a/records.py b/src/records.py similarity index 100% rename from records.py rename to src/records.py diff --git a/resources/droid.mp3 b/src/resources/droid.mp3 similarity index 100% rename from resources/droid.mp3 rename to src/resources/droid.mp3 diff --git a/resources/select.mp3 b/src/resources/select.mp3 similarity index 100% rename from resources/select.mp3 rename to src/resources/select.mp3 diff --git a/resources/surprise.mp3 b/src/resources/surprise.mp3 similarity index 100% rename from resources/surprise.mp3 rename to src/resources/surprise.mp3 diff --git a/ui/helpers/__init__.py b/src/ui/__init__.py similarity index 100% rename from ui/helpers/__init__.py rename to src/ui/__init__.py diff --git a/ui/views/__init__.py b/src/ui/helpers/__init__.py similarity index 100% rename from ui/views/__init__.py rename to src/ui/helpers/__init__.py diff --git a/ui/helpers/constants.py b/src/ui/helpers/constants.py similarity index 100% rename from ui/helpers/constants.py rename to src/ui/helpers/constants.py diff --git a/ui/helpers/discord_helpers.py b/src/ui/helpers/discord_helpers.py similarity index 100% rename from ui/helpers/discord_helpers.py rename to src/ui/helpers/discord_helpers.py diff --git a/ui/helpers/queue_helpers.py b/src/ui/helpers/queue_helpers.py similarity index 100% rename from ui/helpers/queue_helpers.py rename to src/ui/helpers/queue_helpers.py diff --git a/ui/helpers/utils.py b/src/ui/helpers/utils.py similarity index 100% rename from ui/helpers/utils.py rename to src/ui/helpers/utils.py diff --git a/ui/modals.py b/src/ui/modals.py similarity index 100% rename from ui/modals.py rename to src/ui/modals.py diff --git a/src/ui/views/__init__.py b/src/ui/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ui/views/queue_view.py b/src/ui/views/queue_view.py similarity index 100% rename from ui/views/queue_view.py rename to src/ui/views/queue_view.py index ba4f17f..d5dd501 100644 --- a/ui/views/queue_view.py +++ b/src/ui/views/queue_view.py @@ -1,9 +1,9 @@ import discord -from ui.helpers.queue_helpers import can_join_queue from db import get_times_helped_today from ui.modals import HelpModal, PassoffModal, BotIssueModal from ui.helpers.constants import DEFAULT_TIMEOUT, SHORT_TIMEOUT from ui.helpers.discord_helpers import update_queue_messages +from ui.helpers.queue_helpers import can_join_queue class QueueRequests(discord.ui.ActionRow[discord.ui.LayoutView]): @discord.ui.button(label="Need Help", style=discord.ButtonStyle.primary, custom_id="need_help", emoji="πŸ™") diff --git a/ui/views/ta_view.py b/src/ui/views/ta_view.py similarity index 100% rename from ui/views/ta_view.py rename to src/ui/views/ta_view.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_remove_student.py b/tests/test_remove_student.py index 73c8dd8..c7037f5 100644 --- a/tests/test_remove_student.py +++ b/tests/test_remove_student.py @@ -1,11 +1,14 @@ +#This import looks redundant but is needed to run tests +import test_setup + import unittest from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock from datetime import datetime import discord -from records import QueueEntry -from help_queue import HelpQueue +from src.records import QueueEntry +from src.help_queue import HelpQueue """This file conducts tests on the funcitonality of the Remove button using Mocks to simulate interactions with Discord's APi.""" # ========================================================== @@ -29,7 +32,7 @@ def make_mock_interaction(queue: HelpQueue, user_id: int = 999, display_name: str = "TA_Test"): """Construct a mock Interaction with a client.queue.""" mock = MagicMock(spec=discord.Interaction) - mock.client = MagicMock() + mock.client = AsyncMock() mock.client.queue = queue mock.user = MagicMock() mock.user.id = user_id @@ -89,14 +92,14 @@ async def test_get_front_empty_queue(self): class TestRemoveStudentViewConstruction(unittest.IsolatedAsyncioTestCase): def test_options_include_cancel_first(self): - from ui.views.ta_view import RemoveStudentView + from src.ui.views.ta_view import RemoveStudentView view = RemoveStudentView([make_entry(1, username="alice")]) select = view.children[0] self.assertEqual(select.options[0].value, "__cancel__") self.assertEqual(select.options[0].label, "β€” Cancel β€”") def test_emoji_passoff_vs_question(self): - from ui.views.ta_view import RemoveStudentView + from src.ui.views.ta_view import RemoveStudentView entries = [ make_entry(1, username="a", is_passoff=True), make_entry(2, username="b", is_passoff=False), @@ -107,17 +110,17 @@ def test_emoji_passoff_vs_question(self): self.assertEqual(select.options[2].emoji.name, "❓") def test_label_prefers_student_name(self): - from ui.views.ta_view import RemoveStudentView + from src.ui.views.ta_view import RemoveStudentView view = RemoveStudentView([make_entry(1, username="discord_abc", student_name="εΌ δΈ‰")]) self.assertEqual(view.children[0].options[1].label, "εΌ δΈ‰") def test_label_falls_back_to_username(self): - from ui.views.ta_view import RemoveStudentView + from src.ui.views.ta_view import RemoveStudentView view = RemoveStudentView([make_entry(1, username="discord_abc", student_name="")]) self.assertEqual(view.children[0].options[1].label, "discord_abc") def test_label_truncated_at_100_chars(self): - from ui.views.ta_view import RemoveStudentView + from src.ui.views.ta_view import RemoveStudentView view = RemoveStudentView([make_entry(1, username="x", student_name="A" * 150)]) opt = view.children[0].options[1] self.assertLessEqual(len(opt.label), 100) @@ -125,13 +128,13 @@ def test_label_truncated_at_100_chars(self): def test_description_truncated(self): """description is at most 100 characters long (including the "#N " prefix).""" - from ui.views.ta_view import RemoveStudentView + from src.ui.views.ta_view import RemoveStudentView view = RemoveStudentView([make_entry(1, username="x", details="D" * 200)]) opt = view.children[0].options[1] self.assertLessEqual(len(opt.description), 100) def test_value_is_user_id_string(self): - from ui.views.ta_view import RemoveStudentView + from src.ui.views.ta_view import RemoveStudentView view = RemoveStudentView([make_entry(123456, username="alice")]) self.assertEqual(view.children[0].options[1].value, "123456") @@ -144,7 +147,7 @@ class TestRemoveStudentViewCallback(unittest.IsolatedAsyncioTestCase): async def test_cancel_option_defers_and_returns(self): """Select Cancel β†’ defer and do not show the Modal.""" - from ui.views.ta_view import RemoveStudentView + from src.ui.views.ta_view import RemoveStudentView q = HelpQueue() await q.add(make_entry(1, username="alice")) @@ -161,7 +164,7 @@ async def test_cancel_option_defers_and_returns(self): async def test_select_student_sends_modal(self): """Select a student β†’ show RemoveConfirmModal with correct parameters.""" - from ui.views.ta_view import RemoveStudentView + from src.ui.views.ta_view import RemoveStudentView q = HelpQueue() await q.add(make_entry(1, username="alice", student_name="Alice")) @@ -180,7 +183,7 @@ async def test_select_student_sends_modal(self): async def test_student_already_removed_by_another_ta(self): """Concurrency: the selected student has already been removed by another TA.""" - from ui.views.ta_view import RemoveStudentView + from src.ui.views.ta_view import RemoveStudentView q = HelpQueue() # entry is not in the queue (simulate already removed) entry = make_entry(2, username="bob") @@ -205,13 +208,13 @@ async def test_student_already_removed_by_another_ta(self): class TestRemoveConfirmModal(unittest.IsolatedAsyncioTestCase): def test_init_stores_user_id_and_display_name(self): - from ui.modals import RemoveConfirmModal + from src.ui.modals import RemoveConfirmModal modal = RemoveConfirmModal(123, "Alice") self.assertEqual(modal.student_user_id, 123) self.assertEqual(modal.student_name, "Alice") async def test_on_submit_removes_student(self): - from ui.modals import RemoveConfirmModal + from src.ui.modals import RemoveConfirmModal q = HelpQueue() await q.add(make_entry(1, username="alice")) await q.add(make_entry(2, username="bob")) @@ -223,8 +226,8 @@ async def test_on_submit_removes_student(self): mock_user.send = AsyncMock() interaction.client.fetch_user = AsyncMock(return_value=mock_user) - with patch("ui.modals.update_queue_messages", AsyncMock()), \ - patch("ui.modals.notify_next_if_changed", AsyncMock()): + with patch("src.ui.modals.update_queue_messages", AsyncMock()), \ + patch("src.ui.modals.notify_next_if_changed", AsyncMock()): modal = RemoveConfirmModal(1, "Alice") await modal.on_submit(interaction) @@ -233,7 +236,7 @@ async def test_on_submit_removes_student(self): async def test_on_submit_notifies_new_front(self): """Removing the front of the queue calls notify_next_if_changed to notify the new front.""" - from ui.modals import RemoveConfirmModal + from src.ui.modals import RemoveConfirmModal q = HelpQueue() await q.add(make_entry(1, username="alice")) await q.add(make_entry(2, username="bob")) @@ -245,8 +248,8 @@ async def test_on_submit_notifies_new_front(self): mock_user.send = AsyncMock() interaction.client.fetch_user = AsyncMock(return_value=mock_user) - with patch("ui.modals.update_queue_messages", AsyncMock()), \ - patch("ui.modals.notify_next_if_changed", AsyncMock()) as mock_notify: + with patch("src.ui.modals.update_queue_messages", AsyncMock()), \ + patch("src.ui.modals.notify_next_if_changed", AsyncMock()) as mock_notify: modal = RemoveConfirmModal(1, "Alice") await modal.on_submit(interaction) @@ -256,7 +259,7 @@ async def test_on_submit_notifies_new_front(self): async def test_on_submit_no_notify_when_removed_not_front(self): """Removing a non-front student still calls notify_next_if_changed, but the old front remains unchanged.""" - from ui.modals import RemoveConfirmModal + from src.ui.modals import RemoveConfirmModal q = HelpQueue() await q.add(make_entry(1, username="alice")) await q.add(make_entry(2, username="bob")) @@ -268,8 +271,8 @@ async def test_on_submit_no_notify_when_removed_not_front(self): mock_user.send = AsyncMock() interaction.client.fetch_user = AsyncMock(return_value=mock_user) - with patch("ui.modals.update_queue_messages", AsyncMock()), \ - patch("ui.modals.notify_next_if_changed", AsyncMock()) as mock_notify: + with patch("src.ui.modals.update_queue_messages", AsyncMock()), \ + patch("src.ui.modals.notify_next_if_changed", AsyncMock()) as mock_notify: modal = RemoveConfirmModal(2, "Bob") await modal.on_submit(interaction) @@ -279,7 +282,7 @@ async def test_on_submit_no_notify_when_removed_not_front(self): self.assertEqual(mock_notify.call_args[0][1].user_id, 1) async def test_on_submit_dm_to_student(self): - from ui.modals import RemoveConfirmModal + from src.ui.modals import RemoveConfirmModal q = HelpQueue() await q.add(make_entry(1, username="alice")) @@ -290,8 +293,8 @@ async def test_on_submit_dm_to_student(self): mock_user.send = AsyncMock() interaction.client.fetch_user = AsyncMock(return_value=mock_user) - with patch("ui.modals.update_queue_messages", AsyncMock()), \ - patch("ui.modals.notify_next_if_changed", AsyncMock()): + with patch("src.ui.modals.update_queue_messages", AsyncMock()), \ + patch("src.ui.modals.notify_next_if_changed", AsyncMock()): modal = RemoveConfirmModal(1, "Alice") await modal.on_submit(interaction) @@ -299,7 +302,7 @@ async def test_on_submit_dm_to_student(self): self.assertIn("removed from the CS240 help queue", mock_user.send.call_args[0][0]) async def test_on_submit_dm_includes_reason(self): - from ui.modals import RemoveConfirmModal + from src.ui.modals import RemoveConfirmModal q = HelpQueue() await q.add(make_entry(1, username="alice")) @@ -310,8 +313,8 @@ async def test_on_submit_dm_includes_reason(self): mock_user.send = AsyncMock() interaction.client.fetch_user = AsyncMock(return_value=mock_user) - with patch("ui.modals.update_queue_messages", AsyncMock()), \ - patch("ui.modals.notify_next_if_changed", AsyncMock()): + with patch("src.ui.modals.update_queue_messages", AsyncMock()), \ + patch("src.ui.modals.notify_next_if_changed", AsyncMock()): modal = RemoveConfirmModal(1, "Alice") modal.reason = MagicMock() modal.reason.value = "Asked too many questions" @@ -322,7 +325,7 @@ async def test_on_submit_dm_includes_reason(self): self.assertIn("Asked too many questions", dm_text) async def test_on_submit_success_message(self): - from ui.modals import RemoveConfirmModal + from src.ui.modals import RemoveConfirmModal q = HelpQueue() await q.add(make_entry(1, username="alice")) @@ -334,8 +337,8 @@ async def test_on_submit_success_message(self): interaction.client.fetch_user = AsyncMock(return_value=mock_user) interaction.response.send_message = AsyncMock() - with patch("ui.modals.update_queue_messages", AsyncMock()), \ - patch("ui.modals.notify_next_if_changed", AsyncMock()): + with patch("src.ui.modals.update_queue_messages", AsyncMock()), \ + patch("src.ui.modals.notify_next_if_changed", AsyncMock()): modal = RemoveConfirmModal(1, "Alice") await modal.on_submit(interaction) @@ -351,32 +354,46 @@ async def test_on_submit_success_message(self): class TestTAViewRemoveButton(unittest.IsolatedAsyncioTestCase): async def test_empty_queue_shows_message(self): - from ui.views.ta_view import TAView + from src.ui.views.ta_view import TAView q = HelpQueue() interaction = make_mock_interaction(q) view = TAView() - for item in view.children: - if item.custom_id == "remove_from_queue": + # Find the remove button anywhere in the view hierarchy and trigger it. + to_visit = list(view.children) + while to_visit: + item = to_visit.pop(0) + # If this item is a button-like object, it may have a custom_id + if getattr(item, "custom_id", None) == "remove_from_queue": await item.callback(interaction) break + # Otherwise, if it has children (e.g., a Container/ActionRow), search them + children = getattr(item, "children", None) + if children: + to_visit.extend(children) interaction.response.send_message.assert_awaited_once() msg = interaction.response.send_message.call_args[0][0] self.assertEqual(msg, "Queue is empty.") async def test_nonempty_queue_shows_select_with_legend(self): - from ui.views.ta_view import TAView, RemoveStudentView + from src.ui.views.ta_view import TAView, RemoveStudentView q = HelpQueue() await q.add(make_entry(1, username="alice")) interaction = make_mock_interaction(q) view = TAView() - for item in view.children: - if item.custom_id == "remove_from_queue": + # Trigger the remove button wherever it exists inside the view + to_visit = list(view.children) + while to_visit: + item = to_visit.pop(0) + if getattr(item, "custom_id", None) == "remove_from_queue": await item.callback(interaction) break + children = getattr(item, "children", None) + if children: + to_visit.extend(children) interaction.response.send_message.assert_awaited_once() sent_view = interaction.response.send_message.call_args[1]["view"] diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..583ca2e --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,8 @@ +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "src" + +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(SRC)) \ No newline at end of file From af148717e9381d5affe559042832b756a5aa0db3 Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Fri, 12 Jun 2026 15:41:40 -0600 Subject: [PATCH 2/3] Move requirements.txt into docker image --- README.md | 2 +- requirements.txt => src/resources/requirements.txt | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename requirements.txt => src/resources/requirements.txt (100%) diff --git a/README.md b/README.md index 78b56c9..0c84ac9 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Run the following commands in the VSCode terminal: ```powershell python -m pip install --upgrade pip -pip install -r requirements.txt +pip install -r ./src/resources/requirements.txt ``` #### 5. Confirm `ffmpeg` is installed: diff --git a/requirements.txt b/src/resources/requirements.txt similarity index 100% rename from requirements.txt rename to src/resources/requirements.txt From 9e6b06e3ac3847d825162bbe5eba20d79b70894d Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Fri, 12 Jun 2026 16:02:50 -0600 Subject: [PATCH 3/3] specify .env path --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c84ac9..132f84c 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Suggested permissions: #### 4. Save the bot token -Create a file named `.env` with the following contents: +Create a file named `.env` in the `src/resources` directory with the following contents: ```txt TOKEN=your-discord-bot-token-here