From 407e246e49b8819af9107ce299a7f631a265bb6a Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Tue, 10 Mar 2026 16:37:33 -0400 Subject: [PATCH 1/7] feat: integrate post-event rating feedback system (CAPR-44) - Add EventFeedback cog with /event_feedback slash command - DM all guild members a 1-10 rating view after an event - Prompt improvement suggestion via modal for ratings below 6 - Add EventFeedbackSchema (Pydantic) for rating + suggestion - Store responses in-memory with DB call markers for future persistence --- capy_discord/exts/event_feedback/__init__.py | 0 capy_discord/exts/event_feedback/_schemas.py | 13 + .../exts/event_feedback/event_feedback.py | 236 ++++++++++++++++++ 3 files changed, 249 insertions(+) create mode 100644 capy_discord/exts/event_feedback/__init__.py create mode 100644 capy_discord/exts/event_feedback/_schemas.py create mode 100644 capy_discord/exts/event_feedback/event_feedback.py diff --git a/capy_discord/exts/event_feedback/__init__.py b/capy_discord/exts/event_feedback/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/capy_discord/exts/event_feedback/_schemas.py b/capy_discord/exts/event_feedback/_schemas.py new file mode 100644 index 0000000..308e679 --- /dev/null +++ b/capy_discord/exts/event_feedback/_schemas.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, Field + + +class EventFeedbackSchema(BaseModel): + """Pydantic model defining the Event Feedback schema and validation rules.""" + + rating: int = Field(title="Rating", description="Event rating from 1 to 10", ge=1, le=10) + improvement_suggestion: str | None = Field( + title="Improvement Suggestion", + description="What could the club do to make the event better?", + max_length=1000, + default=None, + ) diff --git a/capy_discord/exts/event_feedback/event_feedback.py b/capy_discord/exts/event_feedback/event_feedback.py new file mode 100644 index 0000000..484d882 --- /dev/null +++ b/capy_discord/exts/event_feedback/event_feedback.py @@ -0,0 +1,236 @@ +"""Event feedback extension. + +Sends a DM rating survey (1-10) to every guild member after an event. +Members who rate below 6 are prompted for improvement suggestions. +All responses are stored in-memory (see [DB CALL] comments for future persistence). +""" + +import contextlib +import logging + +import discord +from discord import app_commands, ui +from discord.ext import commands + +from capy_discord.ui.modal import BaseModal +from capy_discord.ui.views import BaseView + +from ._schemas import EventFeedbackSchema + +# Ratings at or above this threshold are considered positive and skip the follow-up question. +_POSITIVE_THRESHOLD = 6 + + +# --------------------------------------------------------------------------- +# Modal - shown only when the user's rating is below the threshold +# --------------------------------------------------------------------------- + + +class ImprovementModal(BaseModal): + """Modal that collects a free-text improvement suggestion.""" + + suggestion: ui.TextInput = ui.TextInput( + label="How could we make the event better?", + placeholder="Share your thoughts with us…", + style=discord.TextStyle.paragraph, + max_length=1000, + required=True, + ) + + def __init__(self, cog: "EventFeedback", rating: int, dm_message: discord.Message | None) -> None: + """Initialize the ImprovementModal. + + Args: + cog: The parent EventFeedback cog that owns the feedback store. + rating: The numeric rating already submitted by the user. + dm_message: The original DM message so the bot can update its buttons. + """ + super().__init__(title="Event Feedback - Tell Us More") + self.cog = cog + self.rating = rating + self.dm_message = dm_message + + async def on_submit(self, interaction: discord.Interaction) -> None: + """Persist feedback and acknowledge the user.""" + await self.cog.save_feedback(interaction, self.rating, self.suggestion.value) + + # Disable buttons on the original DM message now that the flow is complete. + if self.dm_message is not None: + # Direct message edit - does not consume the modal's interaction response. + with contextlib.suppress(discord.HTTPException): + await self.dm_message.edit(view=None) + + +# --------------------------------------------------------------------------- +# Rating button + view +# --------------------------------------------------------------------------- + + +class RatingButton(ui.Button["RatingView"]): + """A single numbered button representing one rating value.""" + + def __init__(self, rating: int) -> None: + """Initialize the RatingButton.""" + style = discord.ButtonStyle.success if rating >= _POSITIVE_THRESHOLD else discord.ButtonStyle.danger + super().__init__(label=str(rating), style=style, row=0 if rating < _POSITIVE_THRESHOLD else 1) + self.rating = rating + + async def callback(self, interaction: discord.Interaction) -> None: + """Handle a button press and branch based on the chosen rating.""" + view: RatingView = self.view # type: ignore[assignment] + + # Guard against double-clicks while the modal/response is in flight. + if view.responded: + await interaction.response.send_message("You've already submitted your rating - thanks!", ephemeral=True) + return + + view.responded = True + view.disable_all_items() + + if self.rating >= _POSITIVE_THRESHOLD: + # Edit the DM in-place to show disabled buttons, then send a follow-up. + await interaction.response.edit_message(view=view) + await self.cog.save_feedback(interaction, self.rating, None) + else: + # Open the improvement modal. The modal's own on_submit will: + # 1. persist the data, and + # 2. edit the original message to remove the buttons. + modal = ImprovementModal(cog=self.cog, rating=self.rating, dm_message=view.dm_message) + await interaction.response.send_modal(modal) + + @property + def cog(self) -> "EventFeedback": + """Return the parent cog through the view reference.""" + view: RatingView = self.view # type: ignore[assignment] + return view.cog + + +class RatingView(BaseView): + """View containing ten rating buttons (1-10) sent in a DM.""" + + def __init__(self, cog: "EventFeedback") -> None: + """Initialize the RatingView with ten rating buttons.""" + super().__init__(timeout=600) # 10-minute window for the user to respond + self.cog = cog + self.responded = False + # Separate from BaseView.message (InteractionMessage) - DMs return discord.Message. + self.dm_message: discord.Message | None = None + + for i in range(1, 11): + self.add_item(RatingButton(i)) + + +# --------------------------------------------------------------------------- +# Cog +# --------------------------------------------------------------------------- + + +class EventFeedback(commands.Cog): + """Collect post-event feedback from all guild members via DM.""" + + def __init__(self, bot: commands.Bot) -> None: + """Initialize the EventFeedback cog.""" + self.bot = bot + self.log = logging.getLogger(__name__) + # [DB CALL]: Replace with actual DB storage in production. + self.feedback_data: dict[int, EventFeedbackSchema] = {} + + # ------------------------------------------------------------------ + # Slash command + # ------------------------------------------------------------------ + + @app_commands.command( + name="event_feedback", + description="Send a post-event feedback survey to all guild members via DM", + ) + @app_commands.default_permissions(manage_guild=True) + async def event_feedback(self, interaction: discord.Interaction) -> None: + """Iterate over guild members and DM each one a rating view.""" + # Defer so we can take our time sending potentially many DMs. + await interaction.response.defer(ephemeral=True) + + guild = interaction.guild + if guild is None: + await interaction.followup.send("❌ This command must be used inside a server.", ephemeral=True) + return + + # For now, all non-bot guild members represent event attendees. + # [ATTENDANCE CALL]: Replace guild.members with the actual attendee list once implemented. + members = [m for m in guild.members if not m.bot] + + sent, failed = 0, 0 + for member in members: + try: + view = RatingView(cog=self) + msg = await member.send( + content=( + f"Hey {member.display_name}! 👋\n\n" + "We'd love your feedback on the recent event.\n" + "Please rate it on a scale of **1-10** by clicking a button below:\n" + "*(🟢 6-10 = Good | 🔴 1-5 = Could be better)*" + ), + view=view, + ) + # Store the message reference so the view can update it later. + view.dm_message = msg + sent += 1 + except discord.Forbidden: + self.log.warning("Could not DM member %s (DMs disabled or bot blocked)", member.id) + failed += 1 + except discord.HTTPException: + self.log.exception("Unexpected error DMing member %s", member.id) + failed += 1 + + self.log.info( + "event_feedback invoked by %s in guild %s: sent=%s failed=%s", + interaction.user.id, + guild.id, + sent, + failed, + ) + + summary = f"✅ Feedback survey sent to **{sent}** member(s)." + if failed: + summary += f"\n⚠️ Could not reach **{failed}** member(s) (DMs may be disabled)." + await interaction.followup.send(summary, ephemeral=True) + + # ------------------------------------------------------------------ + # Shared persistence helper + # ------------------------------------------------------------------ + + async def save_feedback( + self, + interaction: discord.Interaction, + rating: int, + suggestion: str | None, + ) -> None: + """Persist a user's feedback entry and acknowledge them. + + Args: + interaction: The interaction to respond to. + rating: The numeric rating the user chose. + suggestion: Optional improvement text (only present when rating < threshold). + """ + user_id = interaction.user.id + + # [DB CALL]: Upsert a feedback record keyed by user_id. + self.feedback_data[user_id] = EventFeedbackSchema(rating=rating, improvement_suggestion=suggestion) + + self.log.info( + "Feedback saved - user=%s rating=%s suggestion_provided=%s", + user_id, + rating, + suggestion is not None, + ) + + message = "✅ Response recorded! Thank you for your feedback! 🎉" + + if not interaction.response.is_done(): + await interaction.response.send_message(message) + else: + await interaction.followup.send(message) + + +async def setup(bot: commands.Bot) -> None: + """Set up the EventFeedback cog.""" + await bot.add_cog(EventFeedback(bot)) From 4465e9172db5f68029e2ba12500915ecbcd5e146 Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Tue, 10 Mar 2026 17:33:28 -0400 Subject: [PATCH 2/7] feat: add TEST_USER_ID support for event feedback testing (CAPR-44) --- capy_discord/config.py | 3 +++ .../exts/event_feedback/event_feedback.py | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/capy_discord/config.py b/capy_discord/config.py index 532a1de..e0db956 100644 --- a/capy_discord/config.py +++ b/capy_discord/config.py @@ -28,5 +28,8 @@ class Settings(EnvConfig): # Event System Configuration announcement_channel_name: str = "test-announcements" + # Testing + test_user_id: int | None = None + settings = Settings() diff --git a/capy_discord/exts/event_feedback/event_feedback.py b/capy_discord/exts/event_feedback/event_feedback.py index 484d882..75e9a0f 100644 --- a/capy_discord/exts/event_feedback/event_feedback.py +++ b/capy_discord/exts/event_feedback/event_feedback.py @@ -12,6 +12,7 @@ from discord import app_commands, ui from discord.ext import commands +from capy_discord.config import settings from capy_discord.ui.modal import BaseModal from capy_discord.ui.views import BaseView @@ -143,7 +144,8 @@ def __init__(self, bot: commands.Bot) -> None: name="event_feedback", description="Send a post-event feedback survey to all guild members via DM", ) - @app_commands.default_permissions(manage_guild=True) + # TODO: re-enable before production - hides command from non-admins in Discord UI + # @app_commands.default_permissions(manage_guild=True) async def event_feedback(self, interaction: discord.Interaction) -> None: """Iterate over guild members and DM each one a rating view.""" # Defer so we can take our time sending potentially many DMs. @@ -156,7 +158,18 @@ async def event_feedback(self, interaction: discord.Interaction) -> None: # For now, all non-bot guild members represent event attendees. # [ATTENDANCE CALL]: Replace guild.members with the actual attendee list once implemented. - members = [m for m in guild.members if not m.bot] + if settings.test_user_id is not None: + # TEST MODE: only DM the configured test user. + test_member = guild.get_member(settings.test_user_id) + if test_member is None: + await interaction.followup.send( + f"⚠️ TEST MODE: user `{settings.test_user_id}` not found in this guild.", ephemeral=True + ) + return + members = [test_member] + self.log.info("TEST MODE: restricting feedback DM to user %s", settings.test_user_id) + else: + members = [m for m in guild.members if not m.bot] sent, failed = 0, 0 for member in members: From 26b2e95e82b677c0cb5efd513bdda78d6c5b8d0b Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Fri, 13 Mar 2026 17:13:46 -0400 Subject: [PATCH 3/7] Added view_feedback function --- .../exts/event_feedback/event_feedback.py | 157 +++++++++++++++--- 1 file changed, 136 insertions(+), 21 deletions(-) diff --git a/capy_discord/exts/event_feedback/event_feedback.py b/capy_discord/exts/event_feedback/event_feedback.py index 75e9a0f..73d5d90 100644 --- a/capy_discord/exts/event_feedback/event_feedback.py +++ b/capy_discord/exts/event_feedback/event_feedback.py @@ -7,6 +7,8 @@ import contextlib import logging +from datetime import datetime +from zoneinfo import ZoneInfo import discord from discord import app_commands, ui @@ -20,6 +22,7 @@ # Ratings at or above this threshold are considered positive and skip the follow-up question. _POSITIVE_THRESHOLD = 6 +_MAX_REPORT_CHARS = 1900 # --------------------------------------------------------------------------- @@ -134,7 +137,52 @@ def __init__(self, bot: commands.Bot) -> None: self.bot = bot self.log = logging.getLogger(__name__) # [DB CALL]: Replace with actual DB storage in production. - self.feedback_data: dict[int, EventFeedbackSchema] = {} + # feedback_data[guild_id][event_name][user_id] = EventFeedbackSchema(...) + self.feedback_data: dict[int, dict[str, dict[int, EventFeedbackSchema]]] = {} + # Tracks which guild/event a DM feedback response belongs to. + self.pending_feedback_context_by_user: dict[int, tuple[int, str]] = {} + # Snapshot display names at send-time for easier reporting. + self.feedback_user_display_names: dict[int, dict[int, str]] = {} + + def _ensure_feedback_stores(self, guild_id: int, event_name: str) -> None: + """Initialize nested in-memory stores for guild/event feedback.""" + if guild_id not in self.feedback_data: + self.feedback_data[guild_id] = {} + if event_name not in self.feedback_data[guild_id]: + self.feedback_data[guild_id][event_name] = {} + if guild_id not in self.feedback_user_display_names: + self.feedback_user_display_names[guild_id] = {} + + def _default_event_name(self) -> str: + """Build a default event label for feedback batches.""" + timestamp = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M UTC") + return f"Event {timestamp}" + + async def _resolve_target_members( + self, + interaction: discord.Interaction, + guild: discord.Guild, + ) -> list[discord.Member] | None: + """Resolve which members should receive feedback DMs. + + Returns: + A member list, or None if resolution fails and a user-facing message was sent. + """ + # For now, all non-bot guild members represent event attendees. + # [ATTENDANCE CALL]: Replace guild.members with the actual attendee list once implemented. + if settings.test_user_id is None: + return [m for m in guild.members if not m.bot] + + # TEST MODE: only DM the configured test user. + test_member = guild.get_member(settings.test_user_id) + if test_member is None: + await interaction.followup.send( + f"⚠️ TEST MODE: user `{settings.test_user_id}` not found in this guild.", ephemeral=True + ) + return None + + self.log.info("TEST MODE: restricting feedback DM to user %s", settings.test_user_id) + return [test_member] # ------------------------------------------------------------------ # Slash command @@ -156,20 +204,12 @@ async def event_feedback(self, interaction: discord.Interaction) -> None: await interaction.followup.send("❌ This command must be used inside a server.", ephemeral=True) return - # For now, all non-bot guild members represent event attendees. - # [ATTENDANCE CALL]: Replace guild.members with the actual attendee list once implemented. - if settings.test_user_id is not None: - # TEST MODE: only DM the configured test user. - test_member = guild.get_member(settings.test_user_id) - if test_member is None: - await interaction.followup.send( - f"⚠️ TEST MODE: user `{settings.test_user_id}` not found in this guild.", ephemeral=True - ) - return - members = [test_member] - self.log.info("TEST MODE: restricting feedback DM to user %s", settings.test_user_id) - else: - members = [m for m in guild.members if not m.bot] + event_name = self._default_event_name() + + self._ensure_feedback_stores(guild.id, event_name) + members = await self._resolve_target_members(interaction, guild) + if members is None: + return sent, failed = 0, 0 for member in members: @@ -178,7 +218,7 @@ async def event_feedback(self, interaction: discord.Interaction) -> None: msg = await member.send( content=( f"Hey {member.display_name}! 👋\n\n" - "We'd love your feedback on the recent event.\n" + f"We'd love your feedback on **{event_name}**.\n" "Please rate it on a scale of **1-10** by clicking a button below:\n" "*(🟢 6-10 = Good | 🔴 1-5 = Could be better)*" ), @@ -186,6 +226,8 @@ async def event_feedback(self, interaction: discord.Interaction) -> None: ) # Store the message reference so the view can update it later. view.dm_message = msg + self.pending_feedback_context_by_user[member.id] = (guild.id, event_name) + self.feedback_user_display_names[guild.id][member.id] = member.display_name sent += 1 except discord.Forbidden: self.log.warning("Could not DM member %s (DMs disabled or bot blocked)", member.id) @@ -195,18 +237,69 @@ async def event_feedback(self, interaction: discord.Interaction) -> None: failed += 1 self.log.info( - "event_feedback invoked by %s in guild %s: sent=%s failed=%s", + "event_feedback invoked by %s in guild %s for event '%s': sent=%s failed=%s", interaction.user.id, guild.id, + event_name, sent, failed, ) - summary = f"✅ Feedback survey sent to **{sent}** member(s)." + summary = f"✅ Feedback survey for **{event_name}** sent to **{sent}** member(s)." if failed: summary += f"\n⚠️ Could not reach **{failed}** member(s) (DMs may be disabled)." await interaction.followup.send(summary, ephemeral=True) + @app_commands.command( + name="view_feedback", + description="View submitted event feedback for this server", + ) + @app_commands.checks.has_permissions(manage_guild=True) + async def view_feedback(self, interaction: discord.Interaction) -> None: + """Allow server owners/admins to view collected feedback for their guild.""" + guild = interaction.guild + if guild is None: + await interaction.response.send_message("❌ This command must be used inside a server.", ephemeral=True) + return + + guild_feedback = self.feedback_data.get(guild.id, {}) + if not guild_feedback: + await interaction.response.send_message("No feedback has been submitted yet.", ephemeral=True) + return + + name_snapshot = self.feedback_user_display_names.get(guild.id, {}) + blocks: list[str] = [] + for event_name, event_feedback in guild_feedback.items(): + lines: list[str] = [] + for user_id, feedback in event_feedback.items(): + member = guild.get_member(user_id) + user_name = member.display_name if member is not None else name_snapshot.get(user_id, f"User {user_id}") + suggestion = feedback.improvement_suggestion or "(No written feedback)" + lines.append(f"• **{user_name}**\n Rating: **{feedback.rating}/10**\n Feedback: {suggestion}") + + block_body = "\n\n".join(lines) if lines else "(No responses yet)" + blocks.append(f"**Feedback for {event_name}**\n\n{block_body}") + + full_text = "\n\n---\n\n".join(blocks) + if len(full_text) <= _MAX_REPORT_CHARS: + await interaction.response.send_message(full_text, ephemeral=True) + return + + # Fallback: split large reports into multiple ephemeral follow-ups. + await interaction.response.send_message("Feedback report (part 1):", ephemeral=True) + current_chunk = "" + part = 1 + for block in blocks: + entry = f"{block}\n\n" + if len(current_chunk) + len(entry) > _MAX_REPORT_CHARS: + await interaction.followup.send(f"Part {part}:\n{current_chunk}", ephemeral=True) + part += 1 + current_chunk = entry + else: + current_chunk += entry + if current_chunk: + await interaction.followup.send(f"Part {part}:\n{current_chunk}", ephemeral=True) + # ------------------------------------------------------------------ # Shared persistence helper # ------------------------------------------------------------------ @@ -225,12 +318,34 @@ async def save_feedback( suggestion: Optional improvement text (only present when rating < threshold). """ user_id = interaction.user.id + context = self.pending_feedback_context_by_user.get(user_id) + + if context is None: + self.log.warning("Unable to determine guild context for feedback from user %s", user_id) + message = "⚠️ Response received, but I couldn't link it to a server context. Please try again." + if not interaction.response.is_done(): + await interaction.response.send_message(message, ephemeral=True) + else: + await interaction.followup.send(message, ephemeral=True) + return - # [DB CALL]: Upsert a feedback record keyed by user_id. - self.feedback_data[user_id] = EventFeedbackSchema(rating=rating, improvement_suggestion=suggestion) + guild_id, event_name = context + + if guild_id not in self.feedback_data: + self.feedback_data[guild_id] = {} + if event_name not in self.feedback_data[guild_id]: + self.feedback_data[guild_id][event_name] = {} + + # [DB CALL]: Upsert a feedback record keyed by (guild_id, event_name, user_id). + self.feedback_data[guild_id][event_name][user_id] = EventFeedbackSchema( + rating=rating, + improvement_suggestion=suggestion, + ) self.log.info( - "Feedback saved - user=%s rating=%s suggestion_provided=%s", + "Feedback saved - guild=%s event='%s' user=%s rating=%s suggestion_provided=%s", + guild_id, + event_name, user_id, rating, suggestion is not None, From 71486d22fb118218280ab03f19330a53016812a9 Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Fri, 20 Mar 2026 17:04:32 -0400 Subject: [PATCH 4/7] Update event feedback to use custom event names and view by event --- .../exts/event_feedback/event_feedback.py | 292 +++++++++++++----- 1 file changed, 217 insertions(+), 75 deletions(-) diff --git a/capy_discord/exts/event_feedback/event_feedback.py b/capy_discord/exts/event_feedback/event_feedback.py index 73d5d90..ccc75ab 100644 --- a/capy_discord/exts/event_feedback/event_feedback.py +++ b/capy_discord/exts/event_feedback/event_feedback.py @@ -15,6 +15,7 @@ from discord.ext import commands from capy_discord.config import settings +from capy_discord.services.dm import DirectMessenger, Policy from capy_discord.ui.modal import BaseModal from capy_discord.ui.views import BaseView @@ -22,6 +23,7 @@ # Ratings at or above this threshold are considered positive and skip the follow-up question. _POSITIVE_THRESHOLD = 6 +_BAD_THRESHOLD = 3 _MAX_REPORT_CHARS = 1900 @@ -38,27 +40,36 @@ class ImprovementModal(BaseModal): placeholder="Share your thoughts with us…", style=discord.TextStyle.paragraph, max_length=1000, - required=True, + required=False, ) - def __init__(self, cog: "EventFeedback", rating: int, dm_message: discord.Message | None) -> None: + def __init__( + self, cog: "EventFeedback", rating: int, dm_message: discord.Message | None, view: "RatingView | None" = None + ) -> None: """Initialize the ImprovementModal. Args: cog: The parent EventFeedback cog that owns the feedback store. rating: The numeric rating already submitted by the user. dm_message: The original DM message so the bot can update its buttons. + view: The rating view to disable after submission. """ super().__init__(title="Event Feedback - Tell Us More") self.cog = cog self.rating = rating self.dm_message = dm_message + self.view = view async def on_submit(self, interaction: discord.Interaction) -> None: """Persist feedback and acknowledge the user.""" - await self.cog.save_feedback(interaction, self.rating, self.suggestion.value) + suggestion_value = self.suggestion.value.strip() or None + await self.cog.save_feedback(interaction, self.rating, suggestion_value) + + # Mark view as responded and disable buttons on the original DM message. + if self.view is not None: + self.view.responded = True + self.view.disable_all_items() - # Disable buttons on the original DM message now that the flow is complete. if self.dm_message is not None: # Direct message edit - does not consume the modal's interaction response. with contextlib.suppress(discord.HTTPException): @@ -70,36 +81,40 @@ async def on_submit(self, interaction: discord.Interaction) -> None: # --------------------------------------------------------------------------- -class RatingButton(ui.Button["RatingView"]): - """A single numbered button representing one rating value.""" +class RatingRangeButton(ui.Button["RatingView"]): + """A button representing a range of rating values.""" - def __init__(self, rating: int) -> None: - """Initialize the RatingButton.""" - style = discord.ButtonStyle.success if rating >= _POSITIVE_THRESHOLD else discord.ButtonStyle.danger - super().__init__(label=str(rating), style=style, row=0 if rating < _POSITIVE_THRESHOLD else 1) - self.rating = rating + def __init__(self, label: str, style: discord.ButtonStyle, rating_range: tuple[int, int]) -> None: + """Initialize the RatingRangeButton. + + Args: + label: The text label for the button. + style: The color/style of the button. + rating_range: The range of ratings this button represents. + """ + super().__init__(label=label, style=style) + self.rating_range = rating_range async def callback(self, interaction: discord.Interaction) -> None: - """Handle a button press and branch based on the chosen rating.""" + """Handle a button press and branch based on the chosen rating range.""" view: RatingView = self.view # type: ignore[assignment] - # Guard against double-clicks while the modal/response is in flight. if view.responded: await interaction.response.send_message("You've already submitted your rating - thanks!", ephemeral=True) return - view.responded = True - view.disable_all_items() + # Use the average of the range as the representative rating. + average_rating = sum(self.rating_range) // 2 - if self.rating >= _POSITIVE_THRESHOLD: - # Edit the DM in-place to show disabled buttons, then send a follow-up. + if average_rating >= _POSITIVE_THRESHOLD: + # Mark as responded and disable buttons immediately for positive ratings. + view.responded = True + view.disable_all_items() await interaction.response.edit_message(view=view) - await self.cog.save_feedback(interaction, self.rating, None) + await self.cog.save_feedback(interaction, average_rating, None) else: - # Open the improvement modal. The modal's own on_submit will: - # 1. persist the data, and - # 2. edit the original message to remove the buttons. - modal = ImprovementModal(cog=self.cog, rating=self.rating, dm_message=view.dm_message) + # For negative ratings, defer disabling buttons until the modal is submitted. + modal = ImprovementModal(cog=self.cog, rating=average_rating, dm_message=view.dm_message, view=view) await interaction.response.send_modal(modal) @property @@ -110,18 +125,88 @@ def cog(self) -> "EventFeedback": class RatingView(BaseView): - """View containing ten rating buttons (1-10) sent in a DM.""" + """View containing three rating range buttons sent in a DM.""" def __init__(self, cog: "EventFeedback") -> None: - """Initialize the RatingView with ten rating buttons.""" + """Initialize the RatingView with three rating range buttons.""" super().__init__(timeout=600) # 10-minute window for the user to respond self.cog = cog self.responded = False - # Separate from BaseView.message (InteractionMessage) - DMs return discord.Message. self.dm_message: discord.Message | None = None - for i in range(1, 11): - self.add_item(RatingButton(i)) + # Add three buttons for rating ranges. + self.add_item(RatingRangeButton("1-3", discord.ButtonStyle.danger, (1, 3))) + self.add_item(RatingRangeButton("4-6", discord.ButtonStyle.primary, (4, 6))) + self.add_item(RatingRangeButton("7-10", discord.ButtonStyle.success, (7, 10))) + + +# --------------------------------------------------------------------------- +# Event Select Menu +# --------------------------------------------------------------------------- + + +class EventSelect(ui.Select["EventSelectView"]): + """Select menu to choose which event to send feedback for.""" + + def __init__(self) -> None: + """Initialize the EventSelect with event options.""" + options = [ + discord.SelectOption(label="Event 1", value="event1"), + discord.SelectOption(label="Event 2", value="event2"), + discord.SelectOption(label="Event 3", value="event3"), + ] + super().__init__(placeholder="Choose an event...", options=options) + + async def callback(self, interaction: discord.Interaction) -> None: + """Handle event selection.""" + view = self.view + if view is None: + await interaction.response.send_message("Unable to process event selection.", ephemeral=True) + return + selected_event = self.values[0] + await view.cog._send_event_feedback(interaction, selected_event) + + +class EventSelectView(BaseView): + """View containing the event select menu.""" + + def __init__(self, cog: "EventFeedback") -> None: + """Initialize the EventSelectView.""" + super().__init__(timeout=300) + self.cog = cog + self.add_item(EventSelect()) + + +class ViewEventSelect(ui.Select["ViewEventSelectView"]): + """Select menu to choose which event feedback to view.""" + + def __init__(self) -> None: + """Initialize the ViewEventSelect with event options.""" + options = [ + discord.SelectOption(label="Event 1", value="event1"), + discord.SelectOption(label="Event 2", value="event2"), + discord.SelectOption(label="Event 3", value="event3"), + ] + super().__init__(placeholder="Choose an event...", options=options) + + async def callback(self, interaction: discord.Interaction) -> None: + """Handle event selection.""" + view = self.view + if view is None: + await interaction.response.send_message("Unable to process event selection.", ephemeral=True) + return + selected_event = self.values[0] + await view.cog._view_event_feedback(interaction, selected_event) + + +class ViewEventSelectView(BaseView): + """View containing the view event select menu.""" + + def __init__(self, cog: "EventFeedback") -> None: + """Initialize the ViewEventSelectView.""" + super().__init__(timeout=300) + self.cog = cog + self.add_item(ViewEventSelect()) # --------------------------------------------------------------------------- @@ -136,6 +221,7 @@ def __init__(self, bot: commands.Bot) -> None: """Initialize the EventFeedback cog.""" self.bot = bot self.log = logging.getLogger(__name__) + self.dm_service = DirectMessenger() # Initialize the DM service # [DB CALL]: Replace with actual DB storage in production. # feedback_data[guild_id][event_name][user_id] = EventFeedbackSchema(...) self.feedback_data: dict[int, dict[str, dict[int, EventFeedbackSchema]]] = {} @@ -144,6 +230,25 @@ def __init__(self, bot: commands.Bot) -> None: # Snapshot display names at send-time for easier reporting. self.feedback_user_display_names: dict[int, dict[int, str]] = {} + async def send_feedback_dm(self, guild: discord.Guild, member: discord.Member, content: str) -> None: + """Send a feedback DM to a single guild member.""" + try: + policy = Policy(max_recipients=1) # Restrict to one recipient per call + draft = await self.dm_service.compose_to_user(guild, user_id=member.id, content=content, policy=policy) + result = await self.dm_service.send(guild, draft) + + if result.sent_count > 0: + self.log.info("Successfully sent feedback DM to %s", member.display_name) + else: + self.log.warning("Failed to send feedback DM to %s", member.display_name) + except Exception: + self.log.exception("Error sending feedback DM to %s", member.display_name) + + async def collect_feedback(self, guild: discord.Guild, members: list[discord.Member], content: str) -> None: + """Send feedback DMs to a list of guild members.""" + for member in members: + await self.send_feedback_dm(guild, member, content) + def _ensure_feedback_stores(self, guild_id: int, event_name: str) -> None: """Initialize nested in-memory stores for guild/event feedback.""" if guild_id not in self.feedback_data: @@ -155,7 +260,7 @@ def _ensure_feedback_stores(self, guild_id: int, event_name: str) -> None: def _default_event_name(self) -> str: """Build a default event label for feedback batches.""" - timestamp = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M UTC") + timestamp = datetime.now(ZoneInfo("America/New_York")).strftime("%Y-%m-%d %I:%M %p %Z") return f"Event {timestamp}" async def _resolve_target_members( @@ -177,35 +282,41 @@ async def _resolve_target_members( test_member = guild.get_member(settings.test_user_id) if test_member is None: await interaction.followup.send( - f"⚠️ TEST MODE: user `{settings.test_user_id}` not found in this guild.", ephemeral=True + f"TEST MODE: user `{settings.test_user_id}` not found in this guild.", ephemeral=True ) return None self.log.info("TEST MODE: restricting feedback DM to user %s", settings.test_user_id) return [test_member] - # ------------------------------------------------------------------ - # Slash command - # ------------------------------------------------------------------ + def _normalize_event_name(self, event_name: str) -> str | None: + """Normalize an event name from user input.""" + normalized = " ".join(event_name.split()).strip() + return normalized or None @app_commands.command( name="event_feedback", description="Send a post-event feedback survey to all guild members via DM", ) - # TODO: re-enable before production - hides command from non-admins in Discord UI - # @app_commands.default_permissions(manage_guild=True) - async def event_feedback(self, interaction: discord.Interaction) -> None: - """Iterate over guild members and DM each one a rating view.""" + @app_commands.describe(event_name="Name of the event (for example: Spring Social)") + async def event_feedback(self, interaction: discord.Interaction, event_name: str) -> None: + """Send event feedback survey for a user-provided event name.""" + normalized_event_name = self._normalize_event_name(event_name) + if normalized_event_name is None: + await interaction.response.send_message("Please provide a valid event name.", ephemeral=True) + return + await self._send_event_feedback(interaction, normalized_event_name) + + async def _send_event_feedback(self, interaction: discord.Interaction, event_name: str) -> None: + """Iterate over guild members and DM each one a rating view for the specified event.""" # Defer so we can take our time sending potentially many DMs. await interaction.response.defer(ephemeral=True) guild = interaction.guild if guild is None: - await interaction.followup.send("❌ This command must be used inside a server.", ephemeral=True) + await interaction.followup.send("This command must be used inside a server.", ephemeral=True) return - event_name = self._default_event_name() - self._ensure_feedback_stores(guild.id, event_name) members = await self._resolve_target_members(interaction, guild) if members is None: @@ -215,15 +326,12 @@ async def event_feedback(self, interaction: discord.Interaction) -> None: for member in members: try: view = RatingView(cog=self) - msg = await member.send( - content=( - f"Hey {member.display_name}! 👋\n\n" - f"We'd love your feedback on **{event_name}**.\n" - "Please rate it on a scale of **1-10** by clicking a button below:\n" - "*(🟢 6-10 = Good | 🔴 1-5 = Could be better)*" - ), - view=view, + content = ( + f"Hey {member.display_name}!\n\n" + f"We'd love your feedback on **{event_name}**.\n" + "Please rate it on a scale of **1-10** by clicking a button below:\n" ) + msg = await member.send(content=content, view=view) # Store the message reference so the view can update it later. view.dm_message = msg self.pending_feedback_context_by_user[member.id] = (guild.id, event_name) @@ -245,52 +353,87 @@ async def event_feedback(self, interaction: discord.Interaction) -> None: failed, ) - summary = f"✅ Feedback survey for **{event_name}** sent to **{sent}** member(s)." + summary = f"Feedback survey for **{event_name}** sent to **{sent}** member(s)." if failed: - summary += f"\n⚠️ Could not reach **{failed}** member(s) (DMs may be disabled)." + summary += f"\nCould not reach **{failed}** member(s) (DMs may be disabled)." await interaction.followup.send(summary, ephemeral=True) + def _rating_to_label(self, rating: int) -> str: + """Convert numeric rating to a text label.""" + if rating <= _BAD_THRESHOLD: + return "Bad" + if rating <= _POSITIVE_THRESHOLD: + return "Average" + return "Good" + @app_commands.command( name="view_feedback", - description="View submitted event feedback for this server", + description="View submitted event feedback for a specific event", ) @app_commands.checks.has_permissions(manage_guild=True) - async def view_feedback(self, interaction: discord.Interaction) -> None: - """Allow server owners/admins to view collected feedback for their guild.""" + @app_commands.describe(event_name="Event name to view feedback for") + async def view_feedback(self, interaction: discord.Interaction, event_name: str) -> None: + """View collected feedback for a user-provided event name.""" + normalized_event_name = self._normalize_event_name(event_name) + if normalized_event_name is None: + await interaction.response.send_message("Please provide a valid event name.", ephemeral=True) + return + await self._view_event_feedback(interaction, normalized_event_name) + + @view_feedback.autocomplete("event_name") + async def view_feedback_event_name_autocomplete( + self, + interaction: discord.Interaction, + current: str, + ) -> list[app_commands.Choice[str]]: + """Suggest event names that have stored feedback for this guild.""" + guild = interaction.guild + if guild is None: + return [] + + event_names = sorted(self.feedback_data.get(guild.id, {}).keys()) + current_lower = current.lower().strip() + if current_lower: + event_names = [name for name in event_names if current_lower in name.lower()] + + return [app_commands.Choice(name=name, value=name) for name in event_names[:25]] + + async def _view_event_feedback(self, interaction: discord.Interaction, event_name: str) -> None: + """Allow server owners/admins to view collected feedback for a specific event.""" guild = interaction.guild if guild is None: - await interaction.response.send_message("❌ This command must be used inside a server.", ephemeral=True) + await interaction.response.send_message("This command must be used inside a server.", ephemeral=True) return guild_feedback = self.feedback_data.get(guild.id, {}) - if not guild_feedback: - await interaction.response.send_message("No feedback has been submitted yet.", ephemeral=True) + if not guild_feedback or event_name not in guild_feedback: + await interaction.response.send_message( + f"No feedback has been submitted for **{event_name}**.", ephemeral=True + ) return + event_feedback = guild_feedback[event_name] name_snapshot = self.feedback_user_display_names.get(guild.id, {}) - blocks: list[str] = [] - for event_name, event_feedback in guild_feedback.items(): - lines: list[str] = [] - for user_id, feedback in event_feedback.items(): - member = guild.get_member(user_id) - user_name = member.display_name if member is not None else name_snapshot.get(user_id, f"User {user_id}") - suggestion = feedback.improvement_suggestion or "(No written feedback)" - lines.append(f"• **{user_name}**\n Rating: **{feedback.rating}/10**\n Feedback: {suggestion}") - - block_body = "\n\n".join(lines) if lines else "(No responses yet)" - blocks.append(f"**Feedback for {event_name}**\n\n{block_body}") - - full_text = "\n\n---\n\n".join(blocks) + lines: list[str] = [] + for user_id, feedback in event_feedback.items(): + member = guild.get_member(user_id) + user_name = member.display_name if member is not None else name_snapshot.get(user_id, f"User {user_id}") + suggestion = feedback.improvement_suggestion or "(No written feedback)" + rating_label = self._rating_to_label(feedback.rating) + lines.append(f"• **{user_name}**\n Rating: **{rating_label}**\n Feedback: {suggestion}") + + block_body = "\n\n".join(lines) if lines else "(No responses yet)" + full_text = f"**Feedback for {event_name}**\n\n{block_body}" + if len(full_text) <= _MAX_REPORT_CHARS: await interaction.response.send_message(full_text, ephemeral=True) return - # Fallback: split large reports into multiple ephemeral follow-ups. - await interaction.response.send_message("Feedback report (part 1):", ephemeral=True) + await interaction.response.send_message(f"Feedback for {event_name} (part 1):", ephemeral=True) current_chunk = "" part = 1 - for block in blocks: - entry = f"{block}\n\n" + for line in lines: + entry = f"{line}\n\n" if len(current_chunk) + len(entry) > _MAX_REPORT_CHARS: await interaction.followup.send(f"Part {part}:\n{current_chunk}", ephemeral=True) part += 1 @@ -300,7 +443,6 @@ async def view_feedback(self, interaction: discord.Interaction) -> None: if current_chunk: await interaction.followup.send(f"Part {part}:\n{current_chunk}", ephemeral=True) - # ------------------------------------------------------------------ # Shared persistence helper # ------------------------------------------------------------------ @@ -322,7 +464,7 @@ async def save_feedback( if context is None: self.log.warning("Unable to determine guild context for feedback from user %s", user_id) - message = "⚠️ Response received, but I couldn't link it to a server context. Please try again." + message = "Response received, but I couldn't link it to a server context. Please try again." if not interaction.response.is_done(): await interaction.response.send_message(message, ephemeral=True) else: @@ -351,7 +493,7 @@ async def save_feedback( suggestion is not None, ) - message = "✅ Response recorded! Thank you for your feedback! 🎉" + message = "Response recorded. Thank you for your feedback." if not interaction.response.is_done(): await interaction.response.send_message(message) From 8b3216052925d5309e7f9103e515e8e642703e9a Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Fri, 27 Mar 2026 16:39:14 -0400 Subject: [PATCH 5/7] Improve event feedback UX and labels --- .../exts/event_feedback/event_feedback.py | 117 +++++++++++++++--- 1 file changed, 98 insertions(+), 19 deletions(-) diff --git a/capy_discord/exts/event_feedback/event_feedback.py b/capy_discord/exts/event_feedback/event_feedback.py index ccc75ab..2b81886 100644 --- a/capy_discord/exts/event_feedback/event_feedback.py +++ b/capy_discord/exts/event_feedback/event_feedback.py @@ -44,7 +44,11 @@ class ImprovementModal(BaseModal): ) def __init__( - self, cog: "EventFeedback", rating: int, dm_message: discord.Message | None, view: "RatingView | None" = None + self, + cog: "EventFeedback", + rating: int, + dm_message: discord.Message | None, + prompt_view: "ImprovementPromptView | None" = None, ) -> None: """Initialize the ImprovementModal. @@ -52,23 +56,22 @@ def __init__( cog: The parent EventFeedback cog that owns the feedback store. rating: The numeric rating already submitted by the user. dm_message: The original DM message so the bot can update its buttons. - view: The rating view to disable after submission. + prompt_view: The improvement prompt view to disable after submission. """ super().__init__(title="Event Feedback - Tell Us More") self.cog = cog self.rating = rating self.dm_message = dm_message - self.view = view + self.prompt_view = prompt_view async def on_submit(self, interaction: discord.Interaction) -> None: """Persist feedback and acknowledge the user.""" suggestion_value = self.suggestion.value.strip() or None await self.cog.save_feedback(interaction, self.rating, suggestion_value) - # Mark view as responded and disable buttons on the original DM message. - if self.view is not None: - self.view.responded = True - self.view.disable_all_items() + if self.prompt_view is not None: + self.prompt_view.responded = True + self.prompt_view.disable_all_items() if self.dm_message is not None: # Direct message edit - does not consume the modal's interaction response. @@ -113,9 +116,9 @@ async def callback(self, interaction: discord.Interaction) -> None: await interaction.response.edit_message(view=view) await self.cog.save_feedback(interaction, average_rating, None) else: - # For negative ratings, defer disabling buttons until the modal is submitted. - modal = ImprovementModal(cog=self.cog, rating=average_rating, dm_message=view.dm_message, view=view) - await interaction.response.send_modal(modal) + # For negative ratings, offer optional written feedback with a Skip option. + prompt_view = ImprovementPromptView(cog=self.cog, rating=average_rating, dm_message=view.dm_message) + await interaction.response.edit_message(view=prompt_view) @property def cog(self) -> "EventFeedback": @@ -134,10 +137,75 @@ def __init__(self, cog: "EventFeedback") -> None: self.responded = False self.dm_message: discord.Message | None = None - # Add three buttons for rating ranges. - self.add_item(RatingRangeButton("1-3", discord.ButtonStyle.danger, (1, 3))) - self.add_item(RatingRangeButton("4-6", discord.ButtonStyle.primary, (4, 6))) - self.add_item(RatingRangeButton("7-10", discord.ButtonStyle.success, (7, 10))) + # Add three text buttons mapped to rating ranges. + self.add_item(RatingRangeButton("Poor", discord.ButtonStyle.danger, (1, 3))) + self.add_item(RatingRangeButton("Average", discord.ButtonStyle.primary, (4, 6))) + self.add_item(RatingRangeButton("Amazing", discord.ButtonStyle.success, (7, 10))) + + +class WriteImprovementButton(ui.Button["ImprovementPromptView"]): + """Button that opens the written-feedback modal.""" + + def __init__(self) -> None: + """Initialize the write-feedback button.""" + super().__init__(label="Write Feedback", style=discord.ButtonStyle.primary) + + async def callback(self, interaction: discord.Interaction) -> None: + """Open the improvement modal.""" + view = self.view + if view is None: + await interaction.response.send_message("Unable to process your response.", ephemeral=True) + return + + if view.responded: + await interaction.response.send_message("You've already submitted your rating - thanks!", ephemeral=True) + return + + modal = ImprovementModal( + cog=view.cog, + rating=view.rating, + dm_message=view.dm_message, + prompt_view=view, + ) + await interaction.response.send_modal(modal) + + +class SkipImprovementButton(ui.Button["ImprovementPromptView"]): + """Button that skips written feedback and records rating only.""" + + def __init__(self) -> None: + """Initialize the skip-feedback button.""" + super().__init__(label="Skip", style=discord.ButtonStyle.secondary) + + async def callback(self, interaction: discord.Interaction) -> None: + """Record rating without written feedback.""" + view = self.view + if view is None: + await interaction.response.send_message("Unable to process your response.", ephemeral=True) + return + + if view.responded: + await interaction.response.send_message("You've already submitted your rating - thanks!", ephemeral=True) + return + + view.responded = True + view.disable_all_items() + await interaction.response.edit_message(view=None) + await view.cog.save_feedback(interaction, view.rating, None) + + +class ImprovementPromptView(BaseView): + """View shown after low ratings with Write Feedback and Skip options.""" + + def __init__(self, cog: "EventFeedback", rating: int, dm_message: discord.Message | None) -> None: + """Initialize the improvement prompt view.""" + super().__init__(timeout=600) + self.cog = cog + self.rating = rating + self.dm_message = dm_message + self.responded = False + self.add_item(WriteImprovementButton()) + self.add_item(SkipImprovementButton()) # --------------------------------------------------------------------------- @@ -328,8 +396,9 @@ async def _send_event_feedback(self, interaction: discord.Interaction, event_nam view = RatingView(cog=self) content = ( f"Hey {member.display_name}!\n\n" - f"We'd love your feedback on **{event_name}**.\n" - "Please rate it on a scale of **1-10** by clicking a button below:\n" + f"Thanks for coming to **{event_name}**!\n" + f"We'd love to hear your feedback.\n" + "Please choose the option that best matches your experience:\n" ) msg = await member.send(content=content, view=view) # Store the message reference so the view can update it later. @@ -361,10 +430,14 @@ async def _send_event_feedback(self, interaction: discord.Interaction, event_nam def _rating_to_label(self, rating: int) -> str: """Convert numeric rating to a text label.""" if rating <= _BAD_THRESHOLD: - return "Bad" + return "Poor" if rating <= _POSITIVE_THRESHOLD: return "Average" - return "Good" + return "Amazing" + + def _rating_bucket_text(self, rating: int) -> str: + """Return a human-friendly rating label without numeric ranges.""" + return self._rating_to_label(rating) @app_commands.command( name="view_feedback", @@ -493,7 +566,13 @@ async def save_feedback( suggestion is not None, ) - message = "Response recorded. Thank you for your feedback." + feedback_status = "Provided" if suggestion else "Skipped" + message = ( + "Response recorded. Thank you for your feedback.\n" + f"Event: **{event_name}**\n" + f"Rating: **{self._rating_bucket_text(rating)}**\n" + f"Written feedback: **{feedback_status}**" + ) if not interaction.response.is_done(): await interaction.response.send_message(message) From 50a98ee319daf6a924a47f2b37fb0773aaa819d9 Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Fri, 27 Mar 2026 16:58:07 -0400 Subject: [PATCH 6/7] Added visual percentages for view_feedback function --- .../exts/event_feedback/event_feedback.py | 103 ++++++++++++++---- 1 file changed, 83 insertions(+), 20 deletions(-) diff --git a/capy_discord/exts/event_feedback/event_feedback.py b/capy_discord/exts/event_feedback/event_feedback.py index 2b81886..47f7589 100644 --- a/capy_discord/exts/event_feedback/event_feedback.py +++ b/capy_discord/exts/event_feedback/event_feedback.py @@ -1,7 +1,7 @@ """Event feedback extension. Sends a DM rating survey (1-10) to every guild member after an event. -Members who rate below 6 are prompted for improvement suggestions. +After selecting a rating, members can optionally submit written feedback or skip. All responses are stored in-memory (see [DB CALL] comments for future persistence). """ @@ -109,16 +109,9 @@ async def callback(self, interaction: discord.Interaction) -> None: # Use the average of the range as the representative rating. average_rating = sum(self.rating_range) // 2 - if average_rating >= _POSITIVE_THRESHOLD: - # Mark as responded and disable buttons immediately for positive ratings. - view.responded = True - view.disable_all_items() - await interaction.response.edit_message(view=view) - await self.cog.save_feedback(interaction, average_rating, None) - else: - # For negative ratings, offer optional written feedback with a Skip option. - prompt_view = ImprovementPromptView(cog=self.cog, rating=average_rating, dm_message=view.dm_message) - await interaction.response.edit_message(view=prompt_view) + # For all ratings, offer optional written feedback with a Skip option. + prompt_view = ImprovementPromptView(cog=self.cog, rating=average_rating, dm_message=view.dm_message) + await interaction.response.edit_message(view=prompt_view) @property def cog(self) -> "EventFeedback": @@ -439,6 +432,65 @@ def _rating_bucket_text(self, rating: int) -> str: """Return a human-friendly rating label without numeric ranges.""" return self._rating_to_label(rating) + def _build_event_feedback_details( + self, + guild: discord.Guild, + event_feedback: dict[int, EventFeedbackSchema], + name_snapshot: dict[int, str], + ) -> tuple[list[str], int, int, int, int]: + """Build per-response detail lines and aggregate counters.""" + lines: list[str] = [] + poor_count = 0 + average_count = 0 + amazing_count = 0 + written_feedback_count = 0 + + for user_id, feedback in event_feedback.items(): + member = guild.get_member(user_id) + user_name = member.display_name if member is not None else name_snapshot.get(user_id, f"User {user_id}") + suggestion = feedback.improvement_suggestion or "(No written feedback)" + rating_label = self._rating_to_label(feedback.rating) + + if rating_label == "Poor": + poor_count += 1 + elif rating_label == "Average": + average_count += 1 + else: + amazing_count += 1 + + if feedback.improvement_suggestion: + written_feedback_count += 1 + + lines.append(f"• **{user_name}**\n Rating: **{rating_label}**\n Feedback: {suggestion}") + + return lines, poor_count, average_count, amazing_count, written_feedback_count + + def _build_summary_block( + self, + event_name: str, + total_responses: int, + counts: dict[str, int], + ) -> str: + """Build the aggregated feedback summary text for admins.""" + poor_count = counts["poor"] + average_count = counts["average"] + amazing_count = counts["amazing"] + written_feedback_count = counts["written"] + + poor_pct = (poor_count / total_responses) * 100 if total_responses else 0 + average_pct = (average_count / total_responses) * 100 if total_responses else 0 + amazing_pct = (amazing_count / total_responses) * 100 if total_responses else 0 + written_pct = (written_feedback_count / total_responses) * 100 if total_responses else 0 + + return ( + f"**Feedback summary for {event_name}**\n" + f"Total responses: **{total_responses}**\n" + f"Poor: **{poor_count}** ({poor_pct:.0f}%)\n" + f"Average: **{average_count}** ({average_pct:.0f}%)\n" + f"Amazing: **{amazing_count}** ({amazing_pct:.0f}%)\n" + f"Written feedback provided: **{written_feedback_count}** ({written_pct:.0f}%)" + ) + @app_commands.command( name="view_feedback", description="View submitted event feedback for a specific event", @@ -487,22 +539,33 @@ async def _view_event_feedback(self, interaction: discord.Interaction, event_nam event_feedback = guild_feedback[event_name] name_snapshot = self.feedback_user_display_names.get(guild.id, {}) - lines: list[str] = [] - for user_id, feedback in event_feedback.items(): - member = guild.get_member(user_id) - user_name = member.display_name if member is not None else name_snapshot.get(user_id, f"User {user_id}") - suggestion = feedback.improvement_suggestion or "(No written feedback)" - rating_label = self._rating_to_label(feedback.rating) - lines.append(f"• **{user_name}**\n Rating: **{rating_label}**\n Feedback: {suggestion}") + lines, poor_count, average_count, amazing_count, written_feedback_count = self._build_event_feedback_details( + guild, + event_feedback, + name_snapshot, + ) + + total_responses = len(event_feedback) + summary_block = self._build_summary_block( + event_name, + total_responses, + { + "poor": poor_count, + "average": average_count, + "amazing": amazing_count, + "written": written_feedback_count, + }, + ) block_body = "\n\n".join(lines) if lines else "(No responses yet)" - full_text = f"**Feedback for {event_name}**\n\n{block_body}" + details_block = f"**Detailed responses for {event_name}**\n\n{block_body}" + full_text = f"{summary_block}\n\n---\n\n{details_block}" if len(full_text) <= _MAX_REPORT_CHARS: await interaction.response.send_message(full_text, ephemeral=True) return - await interaction.response.send_message(f"Feedback for {event_name} (part 1):", ephemeral=True) + await interaction.response.send_message(summary_block, ephemeral=True) current_chunk = "" part = 1 for line in lines: From e1f66eac9775a5d4df285aa8da6419fdc3a348e3 Mon Sep 17 00:00:00 2001 From: Ethan Beloff Date: Fri, 27 Mar 2026 17:22:07 -0400 Subject: [PATCH 7/7] Added anonymous message and made all feedback anonymous --- capy_discord/exts/event_feedback/_schemas.py | 5 +++++ .../exts/event_feedback/event_feedback.py | 21 +++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/capy_discord/exts/event_feedback/_schemas.py b/capy_discord/exts/event_feedback/_schemas.py index 308e679..b911252 100644 --- a/capy_discord/exts/event_feedback/_schemas.py +++ b/capy_discord/exts/event_feedback/_schemas.py @@ -11,3 +11,8 @@ class EventFeedbackSchema(BaseModel): max_length=1000, default=None, ) + anonymous: bool = Field( + title="Anonymous", + description="Whether this feedback should be shown anonymously in admin views.", + default=False, + ) diff --git a/capy_discord/exts/event_feedback/event_feedback.py b/capy_discord/exts/event_feedback/event_feedback.py index 47f7589..59dbd16 100644 --- a/capy_discord/exts/event_feedback/event_feedback.py +++ b/capy_discord/exts/event_feedback/event_feedback.py @@ -391,6 +391,8 @@ async def _send_event_feedback(self, interaction: discord.Interaction, event_nam f"Hey {member.display_name}!\n\n" f"Thanks for coming to **{event_name}**!\n" f"We'd love to hear your feedback.\n" + "\n" + "**All feedback is anonymous.**\n" "Please choose the option that best matches your experience:\n" ) msg = await member.send(content=content, view=view) @@ -434,9 +436,7 @@ def _rating_bucket_text(self, rating: int) -> str: def _build_event_feedback_details( self, - guild: discord.Guild, event_feedback: dict[int, EventFeedbackSchema], - name_snapshot: dict[int, str], ) -> tuple[list[str], int, int, int, int]: """Build per-response detail lines and aggregate counters.""" lines: list[str] = [] @@ -445,9 +445,8 @@ def _build_event_feedback_details( amazing_count = 0 written_feedback_count = 0 - for user_id, feedback in event_feedback.items(): - member = guild.get_member(user_id) - user_name = member.display_name if member is not None else name_snapshot.get(user_id, f"User {user_id}") + for person_counter, feedback in enumerate(event_feedback.values(), start=1): + display_name = f"Response {person_counter}" suggestion = feedback.improvement_suggestion or "(No written feedback)" rating_label = self._rating_to_label(feedback.rating) @@ -461,7 +460,7 @@ def _build_event_feedback_details( if feedback.improvement_suggestion: written_feedback_count += 1 - lines.append(f"• **{user_name}**\n Rating: **{rating_label}**\n Feedback: {suggestion}") + lines.append(f"• **{display_name}**\n Rating: **{rating_label}**\n Feedback: {suggestion}") return lines, poor_count, average_count, amazing_count, written_feedback_count @@ -538,11 +537,8 @@ async def _view_event_feedback(self, interaction: discord.Interaction, event_nam return event_feedback = guild_feedback[event_name] - name_snapshot = self.feedback_user_display_names.get(guild.id, {}) lines, poor_count, average_count, amazing_count, written_feedback_count = self._build_event_feedback_details( - guild, event_feedback, - name_snapshot, ) total_responses = len(event_feedback) @@ -618,15 +614,17 @@ async def save_feedback( self.feedback_data[guild_id][event_name][user_id] = EventFeedbackSchema( rating=rating, improvement_suggestion=suggestion, + anonymous=True, ) self.log.info( - "Feedback saved - guild=%s event='%s' user=%s rating=%s suggestion_provided=%s", + "Feedback saved - guild=%s event='%s' user=%s rating=%s suggestion_provided=%s anonymous=%s", guild_id, event_name, user_id, rating, suggestion is not None, + True, ) feedback_status = "Provided" if suggestion else "Skipped" @@ -634,7 +632,8 @@ async def save_feedback( "Response recorded. Thank you for your feedback.\n" f"Event: **{event_name}**\n" f"Rating: **{self._rating_bucket_text(rating)}**\n" - f"Written feedback: **{feedback_status}**" + f"Written feedback: **{feedback_status}**\n" + "Privacy: **Anonymous**" ) if not interaction.response.is_done():