From 318d0f371ea5cdac4212b69e5b6781b83d2b296d Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 9 Oct 2025 21:23:01 -0700 Subject: [PATCH 01/16] Added latest_thread and daily_reminder to Thread table --- bot/models/extensions/thread.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/models/extensions/thread.py b/bot/models/extensions/thread.py index 6321ef33..3a345085 100644 --- a/bot/models/extensions/thread.py +++ b/bot/models/extensions/thread.py @@ -1,7 +1,8 @@ -from sqlalchemy import Column, Integer, String, Text +from sqlalchemy import Column, Integer, String, Text, Boolean from grace.model import Model from bot import app from bot.classes.recurrence import Recurrence +from typing import Optional class Thread(app.base, Model): @@ -11,6 +12,8 @@ class Thread(app.base, Model): title = Column(String, nullable=False,) content = Column(Text, nullable=False,) _recurrence = Column("recurrence", Integer, nullable=False, default=0) + latest_thread = Column(String, nullable=True,) + daily_reminder = Column(Boolean, nullable=True,) @property def recurrence(self) -> Recurrence: From 1d1d2102c9d4f1e49796cc4a4072294d0f46ad3c Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 9 Oct 2025 21:24:20 -0700 Subject: [PATCH 02/16] added daily reminder option for recurrent threads --- bot/extensions/threads_cog.py | 51 ++++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/bot/extensions/threads_cog.py b/bot/extensions/threads_cog.py index f4642ed5..a0f75c9c 100644 --- a/bot/extensions/threads_cog.py +++ b/bot/extensions/threads_cog.py @@ -27,7 +27,7 @@ class ThreadModal(Modal, title="Thread"): style=TextStyle.paragraph ) - def __init__(self, recurrence: Recurrence, thread: Thread = None): + def __init__(self, recurrence: Recurrence, remainder: bool = None, thread: Thread = None): super().__init__() if thread: @@ -35,6 +35,7 @@ def __init__(self, recurrence: Recurrence, thread: Thread = None): self.thread_content.default = thread.content self.thread = thread + self.thread_remainder = remainder self.thread_recurrence = recurrence async def on_submit(self, interaction: Interaction): @@ -49,6 +50,8 @@ async def create_thread(self, interaction: Interaction): content=self.thread_content.value, recurrence=self.thread_recurrence ) + thread.daily_reminder = self.thread_remainder + await interaction.response.send_message( f'Thread __**{thread.id}**__ created!', ephemeral=True @@ -58,6 +61,7 @@ async def update_thread(self, interaction: Interaction): self.thread.title = self.thread_title.value, self.thread.content = self.thread_content.value, self.thread.recurrence = self.thread_recurrence + self.thread.daily_reminder = self.thread_remainder self.thread.save() @@ -117,6 +121,37 @@ def cog_load(self): timezone=self.timezone )) + # Runs reminders everyday at 12:30 + self.jobs.append(self.bot.scheduler.add_job( + self.daily_reminder, + 'cron', + hour=12, + minute=30, + timezone=self.timezone + )) + + async def daily_reminder(self): + info("Posting daily threads's reminder") + + embed = Embed( + color=self.bot.default_color, + title="🔔 Daily Reminder", + description="Join the discussion in the latest active threads:" + ) + + if threads := Thread.all(): + for thread in threads: + if hasattr(thread, "latest_thread") and thread.latest_thread: + embed.add_field( + name="", + value=f"- [{thread.title}]({thread.latest_thread})", + inline=False + ) + + channel = self.bot.get_channel(self.threads_channel_id) + if channel: + await channel.send(embed=embed) + def cog_unload(self): for job in self.jobs: self.bot.scheduler.remove_job(job.id) @@ -152,7 +187,11 @@ async def post_thread(self, thread: Thread): if channel: message = await channel.send(content=content, embed=embed) - await message.create_thread(name=thread.title) + discord_thread = await message.create_thread(name=thread.title) + + if thread.daily_reminder: + thread.latest_thread = discord_thread.jump_url + thread.save() @hybrid_group(name="threads", help="Commands to manage threads") @has_permissions(administrator=True) @@ -182,8 +221,8 @@ async def list(self, ctx: Context): @threads_group.command(help="Creates a new thread") @has_permissions(administrator=True) - async def create(self, ctx: Context, recurrence: Recurrence): - modal = ThreadModal(recurrence) + async def create(self, ctx: Context, recurrence: Recurrence, remainder: bool): + modal = ThreadModal(recurrence, remainder=remainder) await ctx.interaction.response.send_modal(modal) @threads_group.command(help="Deletes a given thread") @@ -199,9 +238,9 @@ async def delete(self, ctx: Context, thread: int): @threads_group.command(help="Update a thread") @has_permissions(administrator=True) @autocomplete(thread=thread_autocomplete) - async def update(self, ctx: Context, thread: int, recurrence: Recurrence): + async def update(self, ctx: Context, thread: int, recurrence: Recurrence, remainder: bool): if thread := Thread.get(thread): - modal = ThreadModal(recurrence, thread=thread) + modal = ThreadModal(recurrence, remainder=remainder, thread=thread) await ctx.interaction.response.send_modal(modal) else: await ctx.send("Thread not found!", ephemeral=True) From e700ec7d294d53f063565dde289c95b621017e13 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 14 Oct 2025 17:08:25 -0700 Subject: [PATCH 03/16] Added ignore reminder that was archived or locked + skip if there is no threads to be reminded. --- bot/extensions/threads_cog.py | 102 +++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 31 deletions(-) diff --git a/bot/extensions/threads_cog.py b/bot/extensions/threads_cog.py index a0f75c9c..33fa7d9b 100644 --- a/bot/extensions/threads_cog.py +++ b/bot/extensions/threads_cog.py @@ -1,11 +1,15 @@ import traceback -from typing import Optional from logging import info from pytz import timezone from discord import Interaction, Embed, TextStyle from discord.app_commands import Choice, autocomplete from discord.ui import Modal, TextInput -from discord.ext.commands import Cog, has_permissions, hybrid_command, hybrid_group, Context +from discord.ext.commands import ( + Cog, + has_permissions, + hybrid_group, + Context +) from bot.models.extensions.thread import Thread from bot.classes.recurrence import Recurrence from bot.extensions.command_error_handler import send_command_help @@ -27,7 +31,12 @@ class ThreadModal(Modal, title="Thread"): style=TextStyle.paragraph ) - def __init__(self, recurrence: Recurrence, remainder: bool = None, thread: Thread = None): + def __init__( + self, + recurrence: Recurrence, + reminder: bool = None, + thread: Thread = None, + ): super().__init__() if thread: @@ -35,7 +44,7 @@ def __init__(self, recurrence: Recurrence, remainder: bool = None, thread: Threa self.thread_content.default = thread.content self.thread = thread - self.thread_remainder = remainder + self.thread_reminder = reminder self.thread_recurrence = recurrence async def on_submit(self, interaction: Interaction): @@ -50,7 +59,7 @@ async def create_thread(self, interaction: Interaction): content=self.thread_content.value, recurrence=self.thread_recurrence ) - thread.daily_reminder = self.thread_remainder + thread.daily_reminder = self.thread_reminder await interaction.response.send_message( f'Thread __**{thread.id}**__ created!', @@ -58,10 +67,10 @@ async def create_thread(self, interaction: Interaction): ) async def update_thread(self, interaction: Interaction): - self.thread.title = self.thread_title.value, - self.thread.content = self.thread_content.value, + self.thread.title = self.thread_title.value + self.thread.content = self.thread_content.value self.thread.recurrence = self.thread_recurrence - self.thread.daily_reminder = self.thread_remainder + self.thread.daily_reminder = self.thread_reminder self.thread.save() @@ -71,14 +80,21 @@ async def update_thread(self, interaction: Interaction): ) async def on_error(self, interaction: Interaction, error: Exception): - await interaction.response.send_message('Oops! Something went wrong.', ephemeral=True) + await interaction.response.send_message( + 'Oops! Something went wrong.', + ephemeral=True + ) traceback.print_exception(type(error), error, error.__traceback__) -async def thread_autocomplete(_: Interaction, current: str) -> list[Choice[str]]: +async def thread_autocomplete( + _: Interaction, + current: str, +) -> list[Choice[str]]: return [ Choice(name=t.title, value=str(t.id)) - for t in Thread.all() if current.lower() in t.title + for t in Thread.all() + if current.lower() in t.title ] @@ -90,7 +106,6 @@ def __init__(self, bot): self.threads_channel_id = self.required_config self.timezone = timezone("US/Eastern") - def cog_load(self): # Runs everyday at 18:30 self.jobs.append(self.bot.scheduler.add_job( @@ -122,15 +137,18 @@ def cog_load(self): )) # Runs reminders everyday at 12:30 - self.jobs.append(self.bot.scheduler.add_job( - self.daily_reminder, - 'cron', - hour=12, - minute=30, - timezone=self.timezone - )) + self.jobs.append( + self.bot.scheduler.add_job( + self.daily_reminder, + 'cron', + hour=12, + minute=30, + timezone=self.timezone + ) + ) async def daily_reminder(self): + """Send a daily reminder for active threads.""" info("Posting daily threads's reminder") embed = Embed( @@ -141,16 +159,25 @@ async def daily_reminder(self): if threads := Thread.all(): for thread in threads: - if hasattr(thread, "latest_thread") and thread.latest_thread: + discord_thread = await self.bot.fetch_channel( + int(thread.latest_thread) + ) + if getattr(discord_thread, "archived", False) \ + or getattr(discord_thread, "locked", False): + continue # Skip archieved and locked threads + + if hasattr(thread, "latest_thread") \ + and thread.latest_thread and thread.daily_reminder: embed.add_field( name="", - value=f"- [{thread.title}]({thread.latest_thread})", + value=f"- <#{thread.latest_thread}>", inline=False ) - channel = self.bot.get_channel(self.threads_channel_id) - if channel: - await channel.send(embed=embed) + if embed.fields: + channel = self.bot.get_channel(self.threads_channel_id) + if channel: + await channel.send(embed=embed) def cog_unload(self): for job in self.jobs: @@ -190,7 +217,7 @@ async def post_thread(self, thread: Thread): discord_thread = await message.create_thread(name=thread.title) if thread.daily_reminder: - thread.latest_thread = discord_thread.jump_url + thread.latest_thread = discord_thread.id thread.save() @hybrid_group(name="threads", help="Commands to manage threads") @@ -211,7 +238,10 @@ async def list(self, ctx: Context): for thread in threads: embed.add_field( name=f"[{thread.id}] {thread.title}", - value=f"**Recurrence**: {thread.recurrence}", + value=( + f"**Recurrence**: {thread.recurrence}\n" + f"**Reminder**: {thread.daily_reminder}" + ), inline=False ) else: @@ -221,8 +251,13 @@ async def list(self, ctx: Context): @threads_group.command(help="Creates a new thread") @has_permissions(administrator=True) - async def create(self, ctx: Context, recurrence: Recurrence, remainder: bool): - modal = ThreadModal(recurrence, remainder=remainder) + async def create( + self, + ctx: Context, + recurrence: Recurrence, + reminder: bool, + ): + modal = ThreadModal(recurrence, reminder=reminder) await ctx.interaction.response.send_modal(modal) @threads_group.command(help="Deletes a given thread") @@ -238,14 +273,19 @@ async def delete(self, ctx: Context, thread: int): @threads_group.command(help="Update a thread") @has_permissions(administrator=True) @autocomplete(thread=thread_autocomplete) - async def update(self, ctx: Context, thread: int, recurrence: Recurrence, remainder: bool): + async def update( + self, + ctx: Context, + thread: int, + recurrence: Recurrence, + reminder: bool, + ): if thread := Thread.get(thread): - modal = ThreadModal(recurrence, remainder=remainder, thread=thread) + modal = ThreadModal(recurrence, reminder=reminder, thread=thread) await ctx.interaction.response.send_modal(modal) else: await ctx.send("Thread not found!", ephemeral=True) - @threads_group.command(help="Post a given thread") @has_permissions(administrator=True) @autocomplete(thread=thread_autocomplete) From 84d3d4c0bec528ff510d0fdad0d7b6d7a9dcceaa Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 14 Oct 2025 17:08:54 -0700 Subject: [PATCH 04/16] Remove unused import from Thread model and add newline at EOF --- bot/models/extensions/thread.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/models/extensions/thread.py b/bot/models/extensions/thread.py index 3a345085..8ae2f8b3 100644 --- a/bot/models/extensions/thread.py +++ b/bot/models/extensions/thread.py @@ -2,7 +2,6 @@ from grace.model import Model from bot import app from bot.classes.recurrence import Recurrence -from typing import Optional class Thread(app.base, Model): @@ -25,4 +24,4 @@ def recurrence(self, new_recurrence: Recurrence): @classmethod def find_by_recurrence(cls, recurrence: Recurrence) -> 'Recurrence': - return cls.where(_recurrence=recurrence.value) \ No newline at end of file + return cls.where(_recurrence=recurrence.value) From b79d561a9e67b90b54d5e4117d4c6f33bae7e64d Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 14 Oct 2025 17:12:42 -0700 Subject: [PATCH 05/16] update grace-framework version for stagging version purposes [will be reverse once stagging testing done] --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a6cdbfe3..65ac25d8 100755 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ include_package_data=True, install_requires=[ # For now we always want the latest version on github - 'grace-framework @ git+https://github.com/Code-Society-Lab/grace-framework.git@main', + 'grace-framework @ git+https://github.com/Code-Society-Lab/grace-framework@3ca9093f1326069362305cfd4a57041324743d69', 'emoji>=2.1.0', 'nltk', 'discord-pretty-help==2.0.4', From 2526221fa4229706193b9d29c5d7013097d062bd Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 14 Oct 2025 20:41:03 -0700 Subject: [PATCH 06/16] Added initial threads cog unit test --- tests/extensions/test_threads_cog.py | 154 +++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 tests/extensions/test_threads_cog.py diff --git a/tests/extensions/test_threads_cog.py b/tests/extensions/test_threads_cog.py new file mode 100644 index 00000000..aee79eac --- /dev/null +++ b/tests/extensions/test_threads_cog.py @@ -0,0 +1,154 @@ +import pytest + +from bot.extensions.threads_cog import ThreadsCog +from unittest.mock import AsyncMock, MagicMock, patch + + +@pytest.fixture +def mock_bot(): + """Create a mock Discord bot instance.""" + bot = MagicMock() + bot.default_color = 0xFFFFFF + bot.app.config.get = MagicMock(return_value=None) + bot.scheduler = MagicMock() + return bot + + +@pytest.fixture +def threads_cog(mock_bot): + """Instantiate the ThreadsCog with a mock bot.""" + + return ThreadsCog(mock_bot) + + +@pytest.fixture +def dummy_modal(monkeypatch): + """Fixture that patches ThreadModal and records constructor args.""" + called_args = {} + + class DummyModal: + def __init__(self, recurrence, reminder=None, thread=None): + called_args["recurrence"] = recurrence + called_args["reminder"] = reminder + called_args["thread"] = thread + + monkeypatch.setattr("bot.extensions.threads_cog.ThreadModal", DummyModal) + + return called_args + + +@pytest.mark.asyncio +async def test_create_thread_modal_called(threads_cog): + """Verify that thread modal is called.""" + ctx = MagicMock() + ctx.interaction = MagicMock() + ctx.interaction.response = MagicMock() + ctx.interaction.response.send_modal = AsyncMock() + + recurrence = "DAILY" + reminder = True + await threads_cog.create.callback( + threads_cog, + ctx, + recurrence=recurrence, + reminder=reminder, + ) + + ctx.interaction.response.send_modal.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_modal_args(threads_cog, dummy_modal): + ctx = MagicMock() + ctx.interaction = MagicMock() + ctx.interaction.response = MagicMock() + ctx.interaction.response.send_modal = AsyncMock() + + recurrence = "DAILY" + reminder = True + + await threads_cog.create.callback( + threads_cog, + ctx, + recurrence=recurrence, + reminder=reminder, + ) + + ctx.interaction.response.send_modal.assert_awaited_once() + assert dummy_modal["recurrence"] == recurrence + assert dummy_modal["reminder"] == reminder + assert dummy_modal["thread"] is None + + +@pytest.mark.asyncio +async def test_daily_reminder_no_threads(threads_cog, mock_bot): + """Test daily_reminder when there are no threads.""" + with patch("bot.extensions.threads_cog.Thread.all", return_value=[]): + mock_channel = MagicMock() + mock_bot.get_channel.return_value = mock_channel + + await threads_cog.daily_reminder() + mock_channel.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_daily_reminder_with_active_threads(threads_cog, mock_bot): + """Test daily_reminder sends reminders for active threads.""" + thread1 = MagicMock() + thread1.latest_thread = 123 + thread1.daily_reminder = True + thread1.title = "Thread 1" + thread1.content = "Content 1" + + thread2 = MagicMock() + thread2.latest_thread = 456 + thread2.daily_reminder = False # Should not be included + + # discord_thread is not archived or locked + discord_thread = MagicMock() + discord_thread.archived = False + discord_thread.locked = False + + with patch( + "bot.extensions.threads_cog.Thread.all", + return_value=[thread1, thread2] + ): + mock_bot.fetch_channel = AsyncMock(return_value=discord_thread) + mock_channel = MagicMock() + mock_channel.send = AsyncMock() + mock_bot.get_channel.return_value = mock_channel + + await threads_cog.daily_reminder() + + mock_channel.send.assert_awaited_once() + args, kwargs = mock_channel.send.await_args + embed = kwargs.get("embed") + assert embed is not None + assert "Daily Reminder" in embed.title + + assert len(embed.fields) == 1 + assert embed.fields[0].value == f"- <#{thread1.latest_thread}>" + assert any( + thread2.latest_thread != field.value + for field in embed.fields + ) + + +@pytest.mark.asyncio +async def test_daily_reminder_skips_archived_and_locked(threads_cog, mock_bot): + """Test daily_reminder skips archived and locked threads.""" + thread = MagicMock() + thread.latest_thread = 789 + thread.daily_reminder = True + + discord_thread = MagicMock() + discord_thread.archived = True + discord_thread.locked = True + + with patch("bot.extensions.threads_cog.Thread.all", return_value=[thread]): + mock_bot.fetch_channel = AsyncMock(return_value=discord_thread) + mock_channel = MagicMock() + mock_bot.get_channel.return_value = mock_channel + + await threads_cog.daily_reminder() + mock_channel.send.assert_not_called() From 8bf83b7e6fa4e35f05f11b097ed0565353eee899 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 15 Oct 2025 16:37:55 -0700 Subject: [PATCH 07/16] Add Alembic migration to add latest_thread (BigInteger) and daily_reminder (Boolean) columns to threads --- ...dd_latest_thread_and_daily_reminder_to_.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 db/alembic/versions/b7c695397ab2_add_latest_thread_and_daily_reminder_to_.py diff --git a/db/alembic/versions/b7c695397ab2_add_latest_thread_and_daily_reminder_to_.py b/db/alembic/versions/b7c695397ab2_add_latest_thread_and_daily_reminder_to_.py new file mode 100644 index 00000000..a82bcb5f --- /dev/null +++ b/db/alembic/versions/b7c695397ab2_add_latest_thread_and_daily_reminder_to_.py @@ -0,0 +1,27 @@ +"""add_latest_thread_and_daily_reminder_to_threads + +Revision ID: b7c695397ab2 +Revises: cc8da39749e7 +Create Date: 2025-10-15 15:58:03.467659 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "b7c695397ab2" +down_revision = "cc8da39749e7" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("threads", sa.Column("latest_thread", sa.BigInteger(), nullable=True)) + op.add_column("threads", sa.Column("daily_reminder", sa.Boolean())) + + +def downgrade() -> None: + op.drop_column("threads", "latest_thread") + op.drop_column("threads", "daily_reminder") From c263d194a4be8e060da4eab193e3ad28925794e4 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 15 Oct 2025 16:38:46 -0700 Subject: [PATCH 08/16] fix ruff formatting for test --- tests/extensions/test_threads_cog.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/extensions/test_threads_cog.py b/tests/extensions/test_threads_cog.py index aee79eac..75ccc52d 100644 --- a/tests/extensions/test_threads_cog.py +++ b/tests/extensions/test_threads_cog.py @@ -110,8 +110,7 @@ async def test_daily_reminder_with_active_threads(threads_cog, mock_bot): discord_thread.locked = False with patch( - "bot.extensions.threads_cog.Thread.all", - return_value=[thread1, thread2] + "bot.extensions.threads_cog.Thread.all", return_value=[thread1, thread2] ): mock_bot.fetch_channel = AsyncMock(return_value=discord_thread) mock_channel = MagicMock() @@ -128,10 +127,7 @@ async def test_daily_reminder_with_active_threads(threads_cog, mock_bot): assert len(embed.fields) == 1 assert embed.fields[0].value == f"- <#{thread1.latest_thread}>" - assert any( - thread2.latest_thread != field.value - for field in embed.fields - ) + assert any(thread2.latest_thread != field.value for field in embed.fields) @pytest.mark.asyncio From a58874ed868c97765511047c8e26139bec6dd026 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 15 Oct 2025 16:39:12 -0700 Subject: [PATCH 09/16] Refactor Thread model to use Field annotations and simplify recurrence handling Replace explicit SQLAlchemy Column definitions for latest_thread and daily_reminder with typed Field-style annotations (int, bool) and remove the manual recurrence property/setter in favor of the EnumField(Recurrence) definition. --- bot/models/extensions/thread.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/bot/models/extensions/thread.py b/bot/models/extensions/thread.py index a0feb9d5..ae6268e3 100644 --- a/bot/models/extensions/thread.py +++ b/bot/models/extensions/thread.py @@ -4,6 +4,7 @@ from grace.model import Field, Model from lib.fields import EnumField + class Thread(Model): __tablename__ = "threads" @@ -11,16 +12,8 @@ class Thread(Model): title: str content: str = Field(sa_type=Text) recurrence: Recurrence = EnumField(Recurrence, default=Recurrence.NONE) - latest_thread = Column(String, nullable=True,) - daily_reminder = Column(Boolean, nullable=True,) - - @property - def recurrence(self) -> Recurrence: - return Recurrence(self._recurrence) - - @recurrence.setter - def recurrence(self, new_recurrence: Recurrence): - self._recurrence = new_recurrence.value + latest_thread: int + daily_reminder: bool @classmethod def find_by_recurrence(cls, recurrence: Recurrence) -> "Recurrence": From 8ebd90fee767981828b98abdeb09214c179006a5 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 15 Oct 2025 16:42:17 -0700 Subject: [PATCH 10/16] Refactor threads cog: pass daily_reminder to Thread.create, use Thread.update for updates, remove duplicated daily_reminder handler, avoid fetching channels when no latest_thread, and tidy imports/formatting --- bot/extensions/threads_cog.py | 99 +++++++++-------------------------- 1 file changed, 24 insertions(+), 75 deletions(-) diff --git a/bot/extensions/threads_cog.py b/bot/extensions/threads_cog.py index b3bdcc14..f81e68ab 100644 --- a/bot/extensions/threads_cog.py +++ b/bot/extensions/threads_cog.py @@ -4,19 +4,13 @@ from discord import Embed, Interaction, TextStyle from discord.app_commands import Choice, autocomplete -from discord.ext.commands import Cog, Context, has_permissions, hybrid_group + from discord.ui import Modal, TextInput -from discord.ext.commands import ( - Cog, - has_permissions, - hybrid_group, - Context -) +from discord.ext.commands import Cog, has_permissions, hybrid_group, Context from bot.models.extensions.thread import Thread from bot.classes.recurrence import Recurrence from bot.extensions.command_error_handler import send_command_help -from bot.models.extensions.thread import Thread from lib.config_required import cog_config_required @@ -62,20 +56,20 @@ async def create_thread(self, interaction: Interaction): title=self.thread_title.value, content=self.thread_content.value, recurrence=self.thread_recurrence, + daily_reminder=self.thread_reminder, ) - thread.daily_reminder = self.thread_reminder await interaction.response.send_message( f"Thread __**{thread.id}**__ created!", ephemeral=True ) async def update_thread(self, interaction: Interaction): - self.thread.title = self.thread_title.value - self.thread.content = self.thread_content.value - self.thread.recurrence = self.thread_recurrence - self.thread.daily_reminder = self.thread_reminder - - self.thread.save() + self.thread.update( + title=self.thread_title.value, + content=self.thread_content.value, + recurrence=self.thread_recurrence, + daily_reminder=self.thread_reminder, + ) await interaction.response.send_message( f"Thread __**{self.thread.id}**__ updated!", ephemeral=True @@ -142,11 +136,7 @@ def cog_load(self): # Runs reminders everyday at 12:30 self.jobs.append( self.bot.scheduler.add_job( - self.daily_reminder, - "cron", - hour=12, - minute=30, - timezone=self.timezone + self.daily_reminder, "cron", hour=12, minute=30, timezone=self.timezone ) ) @@ -157,67 +147,26 @@ async def daily_reminder(self): embed = Embed( color=self.bot.default_color, title="🔔 Daily Reminder", - description="Join the discussion in the latest active threads:" + description="Join the discussion in the latest active threads:", ) if threads := Thread.all(): for thread in threads: - discord_thread = await self.bot.fetch_channel( - int(thread.latest_thread) - ) - if getattr(discord_thread, "archived", False) \ - or getattr(discord_thread, "locked", False): - continue # Skip archieved and locked threads - - if hasattr(thread, "latest_thread") \ - and thread.latest_thread and thread.daily_reminder: - embed.add_field( - name="", - value=f"- <#{thread.latest_thread}>", - inline=False + if ( + hasattr(thread, "latest_thread") + and thread.latest_thread + and thread.daily_reminder + ): + discord_thread = await self.bot.fetch_channel( + int(thread.latest_thread) ) + if getattr(discord_thread, "archived", False) or getattr( + discord_thread, "locked", False + ): + continue # Skip archieved and locked threads - if embed.fields: - channel = self.bot.get_channel(self.threads_channel_id) - if channel: - await channel.send(embed=embed) - - # Runs reminders everyday at 12:30 - self.jobs.append( - self.bot.scheduler.add_job( - self.daily_reminder, - 'cron', - hour=12, - minute=30, - timezone=self.timezone - ) - ) - - async def daily_reminder(self): - """Send a daily reminder for active threads.""" - info("Posting daily threads's reminder") - - embed = Embed( - color=self.bot.default_color, - title="🔔 Daily Reminder", - description="Join the discussion in the latest active threads:" - ) - - if threads := Thread.all(): - for thread in threads: - discord_thread = await self.bot.fetch_channel( - int(thread.latest_thread) - ) - if getattr(discord_thread, "archived", False) \ - or getattr(discord_thread, "locked", False): - continue # Skip archieved and locked threads - - if hasattr(thread, "latest_thread") \ - and thread.latest_thread and thread.daily_reminder: embed.add_field( - name="", - value=f"- <#{thread.latest_thread}>", - inline=False + name="", value=f"- <#{thread.latest_thread}>", inline=False ) if embed.fields: @@ -283,7 +232,7 @@ async def list(self, ctx: Context): f"**Recurrence**: {thread.recurrence}\n" f"**Reminder**: {thread.daily_reminder}" ), - inline=False + inline=False, ) else: embed.add_field(name="No threads", value="") From e6806dab746a622b8a96fcc7a86c7d132b19065f Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 16 Oct 2025 11:57:49 -0700 Subject: [PATCH 11/16] Ran ruff for formatting --- bot/models/extensions/thread.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/models/extensions/thread.py b/bot/models/extensions/thread.py index ae6268e3..c80ea4cb 100644 --- a/bot/models/extensions/thread.py +++ b/bot/models/extensions/thread.py @@ -11,7 +11,9 @@ class Thread(Model): id: int | None = Field(default=None, primary_key=True) title: str content: str = Field(sa_type=Text) - recurrence: Recurrence = EnumField(Recurrence, default=Recurrence.NONE) + recurrence: Recurrence = EnumField( + Recurrence, default=Recurrence.NONE, nullable=False + ) latest_thread: int daily_reminder: bool From 246976009ec9fb7e4666e95f554eb615bac29fa2 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 18 Oct 2025 23:13:41 -0700 Subject: [PATCH 12/16] Rename latest_thread to latest_thread_id in Thread model and Alembic migration --- bot/models/extensions/thread.py | 2 +- ...b7c695397ab2_add_latest_thread_and_daily_reminder_to_.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/models/extensions/thread.py b/bot/models/extensions/thread.py index c80ea4cb..94e57a81 100644 --- a/bot/models/extensions/thread.py +++ b/bot/models/extensions/thread.py @@ -14,7 +14,7 @@ class Thread(Model): recurrence: Recurrence = EnumField( Recurrence, default=Recurrence.NONE, nullable=False ) - latest_thread: int + latest_thread_id: int daily_reminder: bool @classmethod diff --git a/db/alembic/versions/b7c695397ab2_add_latest_thread_and_daily_reminder_to_.py b/db/alembic/versions/b7c695397ab2_add_latest_thread_and_daily_reminder_to_.py index a82bcb5f..6f5f443d 100644 --- a/db/alembic/versions/b7c695397ab2_add_latest_thread_and_daily_reminder_to_.py +++ b/db/alembic/versions/b7c695397ab2_add_latest_thread_and_daily_reminder_to_.py @@ -18,10 +18,12 @@ def upgrade() -> None: - op.add_column("threads", sa.Column("latest_thread", sa.BigInteger(), nullable=True)) + op.add_column( + "threads", sa.Column("latest_thread_id", sa.BigInteger(), nullable=True) + ) op.add_column("threads", sa.Column("daily_reminder", sa.Boolean())) def downgrade() -> None: - op.drop_column("threads", "latest_thread") + op.drop_column("threads", "latest_thread_id") op.drop_column("threads", "daily_reminder") From 2f14160b5d349505fdb97ddaf7edcac2f1e49ed8 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 18 Oct 2025 23:13:58 -0700 Subject: [PATCH 13/16] ran ruff format --- bot/models/channel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/models/channel.py b/bot/models/channel.py index 4b88c23f..1cbecb84 100644 --- a/bot/models/channel.py +++ b/bot/models/channel.py @@ -10,4 +10,4 @@ class Channel(Model): ) channel_name: str = Field(primary_key=True) - channel_id: int = Field(primary_key=True) \ No newline at end of file + channel_id: int = Field(primary_key=True) From 04a54c3bd5b99b7e99b58b4040cd526b7daecd88 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 18 Oct 2025 23:16:19 -0700 Subject: [PATCH 14/16] simplify threads fetch --- bot/extensions/threads_cog.py | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/bot/extensions/threads_cog.py b/bot/extensions/threads_cog.py index f81e68ab..389e931d 100644 --- a/bot/extensions/threads_cog.py +++ b/bot/extensions/threads_cog.py @@ -150,24 +150,19 @@ async def daily_reminder(self): description="Join the discussion in the latest active threads:", ) - if threads := Thread.all(): + if threads := Thread.where( + Thread.latest_thread_id.isnot(None), daily_reminder=True + ).all(): for thread in threads: - if ( - hasattr(thread, "latest_thread") - and thread.latest_thread - and thread.daily_reminder - ): - discord_thread = await self.bot.fetch_channel( - int(thread.latest_thread) - ) - if getattr(discord_thread, "archived", False) or getattr( - discord_thread, "locked", False - ): - continue # Skip archieved and locked threads - - embed.add_field( - name="", value=f"- <#{thread.latest_thread}>", inline=False - ) + discord_thread = await self.bot.fetch_channel( + int(thread.latest_thread_id) + ) + if discord_thread.archived or discord_thread.locked: + continue # Skip archieved and locked threads + + embed.add_field( + name="", value=f"- <#{thread.latest_thread_id}>", inline=False + ) if embed.fields: channel = self.bot.get_channel(self.threads_channel_id) @@ -210,8 +205,7 @@ async def post_thread(self, thread: Thread): discord_thread = await message.create_thread(name=thread.title) if thread.daily_reminder: - thread.latest_thread = discord_thread.id - thread.save() + thread.update(latest_thread_id=discord_thread.id) @hybrid_group(name="threads", help="Commands to manage threads") @has_permissions(administrator=True) From 408ba591252948839d273ade371c1c9dcea26fe5 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 19 Oct 2025 00:43:15 -0700 Subject: [PATCH 15/16] refactor unit test to use the test db instead of patches + added unit test for post thread and unload jobs --- tests/extensions/test_threads_cog.py | 134 +++++++++++++++++++-------- 1 file changed, 93 insertions(+), 41 deletions(-) diff --git a/tests/extensions/test_threads_cog.py b/tests/extensions/test_threads_cog.py index 75ccc52d..c9e681df 100644 --- a/tests/extensions/test_threads_cog.py +++ b/tests/extensions/test_threads_cog.py @@ -1,7 +1,8 @@ import pytest from bot.extensions.threads_cog import ThreadsCog -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock +from bot.models.extensions.thread import Thread @pytest.fixture @@ -83,68 +84,119 @@ async def test_create_modal_args(threads_cog, dummy_modal): @pytest.mark.asyncio async def test_daily_reminder_no_threads(threads_cog, mock_bot): """Test daily_reminder when there are no threads.""" - with patch("bot.extensions.threads_cog.Thread.all", return_value=[]): - mock_channel = MagicMock() - mock_bot.get_channel.return_value = mock_channel + _ = Thread.create( + title="Thread 1", + content="Content 1", + recurrence=None, + daily_reminder=False, # Should not be included + latest_thread_id=123, + ) - await threads_cog.daily_reminder() - mock_channel.send.assert_not_called() + mock_channel = MagicMock() + mock_channel.send = AsyncMock() + mock_bot.get_channel.return_value = mock_channel + await threads_cog.daily_reminder() + mock_channel.send.assert_not_called() @pytest.mark.asyncio async def test_daily_reminder_with_active_threads(threads_cog, mock_bot): """Test daily_reminder sends reminders for active threads.""" - thread1 = MagicMock() - thread1.latest_thread = 123 - thread1.daily_reminder = True - thread1.title = "Thread 1" - thread1.content = "Content 1" + thread1 = Thread.create( + title="Thread 1", + content="Content 1", + recurrence=None, + daily_reminder=True, + latest_thread_id=123, + ) - thread2 = MagicMock() - thread2.latest_thread = 456 - thread2.daily_reminder = False # Should not be included + thread2 = Thread.create( + title="Thread 2", + content="Content 2", + recurrence=None, + daily_reminder=False, # Should not be included + latest_thread_id=456, + ) - # discord_thread is not archived or locked discord_thread = MagicMock() discord_thread.archived = False discord_thread.locked = False - with patch( - "bot.extensions.threads_cog.Thread.all", return_value=[thread1, thread2] - ): - mock_bot.fetch_channel = AsyncMock(return_value=discord_thread) - mock_channel = MagicMock() - mock_channel.send = AsyncMock() - mock_bot.get_channel.return_value = mock_channel + mock_channel = MagicMock() + mock_channel.send = AsyncMock() - await threads_cog.daily_reminder() + mock_bot.get_channel.return_value = mock_channel + mock_bot.fetch_channel = AsyncMock(return_value=discord_thread) - mock_channel.send.assert_awaited_once() - args, kwargs = mock_channel.send.await_args - embed = kwargs.get("embed") - assert embed is not None - assert "Daily Reminder" in embed.title + await threads_cog.daily_reminder() + mock_channel.send.assert_awaited_once() - assert len(embed.fields) == 1 - assert embed.fields[0].value == f"- <#{thread1.latest_thread}>" - assert any(thread2.latest_thread != field.value for field in embed.fields) + args, kwargs = mock_channel.send.await_args + embed = kwargs.get("embed") + assert embed is not None + + assert "Daily Reminder" in embed.title + assert len(embed.fields) == 1 + assert embed.fields[0].value == f"- <#{thread1.latest_thread_id}>" + assert any(thread2.latest_thread_id != field.value for field in embed.fields) @pytest.mark.asyncio async def test_daily_reminder_skips_archived_and_locked(threads_cog, mock_bot): """Test daily_reminder skips archived and locked threads.""" - thread = MagicMock() - thread.latest_thread = 789 - thread.daily_reminder = True - discord_thread = MagicMock() discord_thread.archived = True discord_thread.locked = True - with patch("bot.extensions.threads_cog.Thread.all", return_value=[thread]): - mock_bot.fetch_channel = AsyncMock(return_value=discord_thread) - mock_channel = MagicMock() - mock_bot.get_channel.return_value = mock_channel + mock_channel = MagicMock() + mock_channel.send = AsyncMock() + mock_bot.get_channel.return_value = mock_channel + mock_bot.fetch_channel = AsyncMock(return_value=discord_thread) + + await threads_cog.daily_reminder() + mock_channel.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_post_thread(threads_cog, mock_bot): + """Test post_thread creates a thread in the specified channel.""" + thread = Thread.create( + title="Test Thread", + content="This is a test thread.", + recurrence=None, + daily_reminder=False, + latest_thread_id=None, + ) + + mock_channel = MagicMock() + mock_channel.send = AsyncMock() + mock_bot.get_channel.return_value = mock_channel + + message_mock = MagicMock() + message_mock.create_thread = AsyncMock() + mock_channel.send.return_value = message_mock + + await threads_cog.post_thread(thread) + + mock_channel.send.assert_awaited_once() + args, kwargs = mock_channel.send.await_args + embed = kwargs.get("embed") + assert embed is not None + assert embed.title == thread.title + assert embed.description == thread.content + + message_mock.create_thread.assert_awaited_once_with(name=thread.title) + + +@pytest.mark.asyncio +async def test_cog_unload_removes_jobs(threads_cog, mock_bot): + """Test that cog_unload removes scheduled jobs.""" + job1 = MagicMock() + job2 = MagicMock() + threads_cog.jobs = [job1, job2] + + threads_cog.cog_unload() - await threads_cog.daily_reminder() - mock_channel.send.assert_not_called() + mock_bot.scheduler.remove_job.assert_any_call(job1.id) + mock_bot.scheduler.remove_job.assert_any_call(job2.id) + assert mock_bot.scheduler.remove_job.call_count == 2 From 1b30f3efef6e7f8bbbf36fda81e566af81b56a24 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 19 Oct 2025 00:43:31 -0700 Subject: [PATCH 16/16] add test db --- tests/extensions/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/extensions/__init__.py b/tests/extensions/__init__.py index e69de29b..83527638 100644 --- a/tests/extensions/__init__.py +++ b/tests/extensions/__init__.py @@ -0,0 +1,10 @@ +from bot import app +from grace.database import up_migration + +app.load("test") + +app.drop_tables() +app.drop_database() + +app.create_database() +up_migration(app, "head")