Skip to content

Feedback Review Logic

The Feedback Review system allows administrators and developers to analyze user feedback directly within Discord. It combines database queries with matplotlib visualizations to generate interactive reports.

The Administration Cog

This Cog handles the /review_feedback command, which fetches data from the database and passes it to the plotting utilities. It is protected by a permission check (may_review_feedback) ensuring only authorized personnel can access sensitive user feedback. Authorized are the developers, the owner of the Discord application and the administrators of the home server, the one DISCORD_SERVER_ID names. An administrator of any other server is refused: OSCAR is a public bot, and anybody is the administrator of a server they made themselves. The answer is ephemeral, so the channel does not see the charts.

src.oscar.cogs.admin.Administration

Administration(bot: Bot)

Bases: Cog

Class for the administration cog.

Source code in src/oscar/cogs/admin.py
def __init__(self, bot: commands.Bot):
    self.bot: commands.Bot = bot
    logger.info("Loaded Administration cog")

review_feedback async

review_feedback(interaction: Interaction)

Command to review feedback given by users.

Source code in src/oscar/cogs/admin.py
@app_commands.default_permissions(administrator=True)
@app_commands.command(description="Review the given feedback")
@app_commands.check(may_review_feedback)
async def review_feedback(self, interaction: discord.Interaction):
    """ Command to review feedback given by users."""
    # \/ important (makes the bot say it"s thinking)
    # ephemeral: the plots are built from what students wrote, the channel has no
    # business reading along
    _ = await interaction.response.defer(ephemeral=True)
    db: Database = get_database()
    feedback: list[FeedbackReviewDict] = db.get_feedback()

    boxplot_file: File = await create_feedback_boxplot(feedback)
    timeline_file: File = await create_feedback_timeline(feedback)

    await interaction.followup.send(
        view=ReviewFeedbackView(
            interaction=interaction,
            feedback=feedback,
            images=[boxplot_file, timeline_file],
        ),
        files=[boxplot_file, timeline_file],
        ephemeral=True,
    )

The Review View

The ReviewFeedbackView is the interactive UI component. It extends TranslatedView to support both English and German.

Key Features:

  • Dynamic Filtering: Filters feedback by time range (1 Hour, 12 Hours, 1 Day, 1 Week, 1/6 Months, 1/2/4 Years, All Time).
  • Live Updates: Regenerates charts on-the-fly when filters change.
  • Data Export: Includes a download button (⤓) to export the complete dataset as a .json file.

GDPR & Confidentiality Notice: The exported JSON contains pseudonymized feedback linked to Discord User IDs. As an administrator, you must treat this data as confidential and delete local exports once the analysis is complete. Ensure exports are not shared publicly.

src.oscar.cogs.admin.ReviewFeedbackView

ReviewFeedbackView(interaction: Interaction, feedback: list[FeedbackReviewDict], images: list[File], default_language: LanguageCode = LanguageCode.EN)

Bases: TranslatedView

Feedback (review) view that extends TranslatedView for language switching.

Source code in src/oscar/cogs/admin.py
def __init__(
        self,
        interaction: discord.Interaction,
        feedback: list[FeedbackReviewDict],
        images: list[discord.File],
        default_language: LanguageCode = LanguageCode.EN
    ):
    self.interaction: Interaction[Client] = interaction
    self.feedback = feedback
    self.files = images


    # make periods unique and sorted with None at the end
    self.periods = list(set(self.periods))
    self.periods.remove(None)
    self.periods.sort()  # pyright: ignore[reportCallIssue]
    # TODO: add code to only select valid (sensible) time spans
    self.periods.append(None)

    # limit time spans to a max of 5 options
    if len(self.periods) == 0:
        self.periods = [None]
    elif len(self.periods) > 5:
        step_size: float = (len(self.periods) - 1) / 4
        indices_to_keep: list[int] = [0]
        for i in range(1, 4):
            indices_to_keep.append(int(round(i * step_size)))
        self.periods = [self.periods[i] for i in indices_to_keep]
        self.periods.append(None)

    self.__filter_feedback()

    super().__init__(default_language)

_build_content

_build_content()

Build the feedback (review) view content with current language.

Source code in src/oscar/cogs/admin.py
@override
def _build_content(self):
    """ Build the feedback (review) view content with current language."""

    # time_span_buttons: list[Button[LayoutView]] = []
    time_span_buttons: ActionRow[LayoutView] = ActionRow()
    for duration in self.periods:
        button: Button[LayoutView] = Button(
            id = duration,
            label=format_duration(duration, self.language_code),
            disabled=self.selected_zoom == duration,
            style=discord.ButtonStyle.primary if self.selected_zoom == duration else
                  discord.ButtonStyle.secondary
        )

        async def time_span_callback(
            interaction: discord.Interaction,
            duration: int|None = duration
        ):
            if not await self.check_user(interaction):
                return
            if duration == self.selected_zoom:
                return

            self.selected_zoom = duration
            self.__filter_feedback()
            await self._update(interaction)

        button.callback = time_span_callback
        _ = time_span_buttons.add_item(button)


    download_button: Button[LayoutView] = Button(
        label="⤓",
        disabled=len(self.feedback)==0,
        style=discord.ButtonStyle.secondary,
    )

    async def download_callback(interaction: discord.Interaction):
        if not await self.check_user(interaction):
            return

        json_data: str = json.dumps(self.feedback, indent=4)
        json_bytes: BytesIO = io.BytesIO(json_data.encode('utf-8'))

        file: File = File(json_bytes, filename="feedback_data.json")

        _ = await interaction.response.send_message(
            FEEDBACK_REVIEW["finished_download"][self.language_code],
            file=file,
            ephemeral=True
        )

    download_button.callback = download_callback


    _ = self.add_item(
        Container(
            Section[LayoutView](
                TextDisplay(f"# {FEEDBACK_REVIEW['review_feedback'][self.language_code]}"),
                accessory=self._create_language_toggle(),
            ),
            Separator(),

            # TextDisplay(f"## {FEEDBACK_REVIEW["intuitiveness"][self.language_code]}"),
            TextDisplay(f"-# {FEEDBACK_REVIEW['zoom'][self.language_code]}"),
            time_span_buttons,
            Separator(),
            Separator(),

            MediaGallery(
                MediaGalleryItem(self.files[0]), # boxplot image
            ),
            Separator(),
            MediaGallery(
                MediaGalleryItem(self.files[1]), # timeline image
            ),
            Separator(),
            Separator(),

            # section for sending complete feedback data as a `.json` to one"s own dms
            Section[LayoutView](
                TextDisplay(
                    f"-# {FEEDBACK_REVIEW['data_download'][self.language_code]} _(`.json`)_"
                ),
                accessory=download_button,
            ),
        )
    )

Visualizations (Matplotlib)

The bot uses matplotlib with the Agg backend (headless) to generate images purely in memory (io.BytesIO), preventing GUI blocking on the server.

Limitation (Discord File Sizes): Discord enforces an 8 MB upload limit (25 MB for Nitro servers). For extremely massive datasets, the generated PNG or JSON export may hit this API limit. This typically requires tens of thousands of records.

Boxplots

Visualizes the statistical distribution of the three core metrics on a Likert scale from 1 (Very Poor/Disagree) to 4 (Excellent/Agree). It shows medians, quartiles, and outliers.

src.util.plots.create_feedback_boxplot async

create_feedback_boxplot(feedback_data: list[FeedbackReviewDict], language_code: LanguageCode = LanguageCode.EN) -> File

Create a boxplot visualization of feedback metrics.

Parameters:

Name Type Description Default
feedback_data list[FeedbackReviewDict]

List of feedback dictionaries

required

Returns:

Type Description
File

Discord file object containing the plot image

Source code in src/util/plots.py
async def create_feedback_boxplot(
        feedback_data: list[FeedbackReviewDict],
        language_code: LanguageCode=LanguageCode.EN
    ) -> File:
    """
    Create a boxplot visualization of feedback metrics.

    Args:
        feedback_data: List of feedback dictionaries

    Returns:
        Discord file object containing the plot image
    """
    if not feedback_data:
        logger.warning("No feedback data provided for boxplot creation.")

    # Extract the three metrics
    intuitiveness: list[float] = [float(fb["intuitiveness"]) for fb in feedback_data]
    discoverability: list[float] = [float(fb["discoverability"]) for fb in feedback_data]
    usefulness: list[float] = [float(fb["usefulness"]) for fb in feedback_data]

    # Create the plot
    fig, ax = plt.subplots(figsize=(8, 6))
    fig.patch.set_facecolor(SURFACE_COLOR)

    # Create boxplots
    bp = ax.boxplot(
        [intuitiveness, discoverability, usefulness],
        labels=[
            FEEDBACK_REVIEW["intuitiveness"][language_code],
            FEEDBACK_REVIEW["discoverability"][language_code],
            FEEDBACK_REVIEW["usefulness"][language_code]
        ],
        patch_artist=True,
        notch=True,
    )

    # Style the boxplots
    for patch in bp["boxes"]:
        patch.set_facecolor(PRIMARY_COLOR)
        patch.set_edgecolor(SURFACE_COLOR)
        patch.set_alpha(0.7)

        # Style text and foreground elements
    for whisker in bp["whiskers"]:
        whisker.set_color(FOREGROUND_COLOR)
        whisker.set_linewidth(1.5)
    for cap in bp["caps"]:
        cap.set_color(FOREGROUND_COLOR)
        cap.set_linewidth(1.5)
    for median in bp["medians"]:
        median.set_color(FOREGROUND_COLOR)
        median.set_linewidth(2.0)
    for flier in bp["fliers"]:
        # set outlier marker edge color to foreground color
        flier.set_markeredgecolor(FOREGROUND_COLOR)
        # optional: make marker face match background so it looks outlined
        flier.set_markerfacecolor(SURFACE_COLOR)
        flier.set_markersize(5)

    # Axes / background
    ax.patch.set_color(BACKGROUND_COLOR)
    ax.spines["bottom"].set_color(FOREGROUND_COLOR)
    ax.spines["left"].set_color(FOREGROUND_COLOR)
    ax.spines["top"].set_visible(False)
    ax.spines["right"].set_visible(False)
    ax.tick_params(colors=FOREGROUND_COLOR)

    # Ticks and tick labels
    ax.tick_params(colors=FOREGROUND_COLOR, labelsize=12)
    ax.set_xticklabels(ax.get_xticklabels(), fontsize=16, color=FOREGROUND_COLOR)

    # Set labels and title
    ax.set_ylabel(FEEDBACK_REVIEW["rating"][language_code], fontsize=14, color=FOREGROUND_COLOR)
    _ = ax.set_ylim(0, 5)
    ax.set_title(
        # pylint: disable=C0301 # (line-too-long)
        label=f"{FEEDBACK_REVIEW['metrics_overview'][language_code]} ({len(feedback_data)} {FEEDBACK_REVIEW["responses"][language_code]})\n{time.strftime("%d-%m-%Y %H:%M:%S", time.localtime(time.time()))}",
        fontsize=18,
        fontweight="bold",
        color=FOREGROUND_COLOR,
        pad=20
    )
    ax.grid(axis="y", color=FOREGROUND_COLOR, alpha=0.3, linestyle="--")

    # Adjust layout
    plt.tight_layout()
    # plt.show()

    # Save to bytes buffer
    buf = io.BytesIO()

    plt.savefig(buf, format="png", dpi=150, bbox_inches="tight")
    _ = buf.seek(0)
    plt.close(fig)

    # Create Discord file
    return File(buf, filename="feedback_metrics_boxplot.png")

Timelines

Visualizes feedback volume over time. It uses dynamic binning (hourly/daily/weekly) depending on the selected time span and highlights the active filter range.

src.util.plots.create_feedback_timeline async

create_feedback_timeline(feedback_data: list[FeedbackReviewDict], selected_duration: int | None = None, language_code: LanguageCode = LanguageCode.EN) -> File

Create a timeline visualization of feedback timestamps with highlighted selected range. Now renders a line graph showing counts of feedback over time and highlights the selected range.

Parameters:

Name Type Description Default
feedback_data list[FeedbackReviewDict]

List of feedback dictionaries containing timestamps

required
selected_duration int | None

Duration in seconds from current time to highlight (None = all time)

None
language_code LanguageCode

Language code for labels

EN

Returns:

Type Description
File

Discord file object containing the timeline plot

Source code in src/util/plots.py
async def create_feedback_timeline(
    feedback_data: list[FeedbackReviewDict],
    selected_duration: int|None = None,
    language_code: LanguageCode = LanguageCode.EN
) -> File:
    """
    Create a timeline visualization of feedback timestamps with highlighted selected range.
    Now renders a line graph showing counts of feedback over time and highlights the selected range.

    Args:
        feedback_data: List of feedback dictionaries containing timestamps
        selected_duration: Duration in seconds from current time to highlight (None = all time)
        language_code: Language code for labels

    Returns:
        Discord file object containing the timeline plot
    """
    if not feedback_data:
        # Create empty plot with message
        fig, ax = plt.subplots(figsize=(12, 4))
        fig.patch.set_facecolor(SURFACE_COLOR)
        ax.patch.set_color(BACKGROUND_COLOR)
        ax.text(0.5, 0.5, 'No feedback data available',
                ha='center', va='center', fontsize=16, color=FOREGROUND_COLOR)
        ax.set_xlim(0, 1)
        ax.set_ylim(0, 1)
        ax.axis('off')
    else:
        # Extract timestamps and convert to datetime
        timestamps = [fb['timestamp'] for fb in feedback_data]
        datetime_objects = [datetime.fromtimestamp(ts) for ts in timestamps]
        datetime_objects.sort()

        current_time = time.time()
        current_datetime = datetime.fromtimestamp(current_time)

        # Determine selected range
        if selected_duration is None:
            cutoff_datetime = None
        else:
            cutoff_time = current_time - selected_duration
            cutoff_datetime = datetime.fromtimestamp(cutoff_time)

        # Determine plotting range: from earliest feedback to now (with small padding)
        start_datetime = datetime_objects[0]
        end_datetime = max(current_datetime, datetime_objects[-1])
        # Add a small padding to end for visual space
        end_datetime = end_datetime + timedelta(hours=6)

        # Choose bin interval based on span
        span_seconds = (end_datetime - start_datetime).total_seconds()
        if span_seconds <= 60 * 60 * 24 * 2:        # <= 2 days -> hourly bins
            delta = timedelta(hours=1)
            fmt = '%d-%m %H:%M'
        elif span_seconds <= 60 * 60 * 24 * 90:     # <= 90 days -> daily bins
            delta = timedelta(days=1)
            fmt = '%d-%m-%Y'
        elif span_seconds <= 60 * 60 * 24 * 730:    # <= 2 years -> weekly bins
            delta = timedelta(weeks=1)
            fmt = '%d-%m-%Y'
        else:                                       # larger -> monthly-ish bins (30 days)
            delta = timedelta(days=30)
            fmt = '%b %Y'

        # Create bin edges as matplotlib date numbers
        edges_dt = list(mdates.drange(start_datetime, end_datetime + delta, delta))
        edges = edges_dt  # already matplotlib date floats

        # Convert each timestamp to matplotlib date number
        md_dates = [mdates.date2num(dt) for dt in datetime_objects]

        # Count occurrences per bin
        counts = [0] * (len(edges) - 1)
        for md in md_dates:
            idx = bisect_right(edges, md) - 1
            if 0 <= idx < len(counts):
                counts[idx] += 1

        # Compute bin centers for plotting the line
        centers = [(edges[i] + edges[i + 1]) / 2 for i in range(len(counts))]

        # Create the plot
        fig, ax = plt.subplots(figsize=(12, 4))
        fig.patch.set_facecolor(SURFACE_COLOR)
        ax.patch.set_color(BACKGROUND_COLOR)

        # convert to numpy for masking and plotting segments with different colors
        centers_np = np.array(centers)
        counts_np = np.array(counts, dtype=float)

        # Determine selected range boundaries
        if cutoff_datetime is None:
            cutoff_md = None
            current_md = None
        else:
            cutoff_md = mdates.date2num(cutoff_datetime)
            current_md = mdates.date2num(current_datetime)

        # Build extended arrays with interpolated points at boundaries
        x_plot = list(centers_np)
        y_plot = list(counts_np)

        if cutoff_md is not None:
            # Add interpolated points at cutoff_md and current_md
            for boundary_md in [cutoff_md, current_md]:
                if centers_np[0] <= boundary_md <= centers_np[-1]:
                    # Find where to insert this boundary point
                    idx = np.searchsorted(centers_np, boundary_md)
                    if 0 <= idx < len(centers_np):
                        # Linear interpolation
                        x0, x1 = centers_np[idx-1], centers_np[idx]
                        y0, y1 = counts_np[idx-1], counts_np[idx]
                        y_interp = y0 + (y1 - y0) * (boundary_md - x0) / (x1 - x0)

                        x_plot.insert(idx, boundary_md)
                        y_plot.insert(idx, y_interp)

        # Sort by x values
        sorted_indices = np.argsort(x_plot)
        x_plot = np.array(x_plot)[sorted_indices]
        y_plot = np.array(y_plot)[sorted_indices]

        # Now create masks for primary/secondary
        if cutoff_md is None:
            selected_mask = np.ones_like(y_plot, dtype=bool)
        else:
            selected_mask = (x_plot >= cutoff_md) & (x_plot <= current_md)

        primary_y = np.where(selected_mask, y_plot, np.nan)
        secondary_y = np.where(~selected_mask, y_plot, np.nan)

        # Helper: plot contiguous non-NaN segments so isolated segments draw correctly
        def _plot_segments(x_arr, y_arr, color, zorder):
            mask = ~np.isnan(y_arr)
            if not np.any(mask):
                return
            idx = np.where(mask)[0]
            runs = np.split(idx, np.where(np.diff(idx) != 1)[0] + 1)
            for run in runs:
                ax.plot(x_arr[run], y_arr[run], '-', color=color, linewidth=2, zorder=zorder)
                # If primary, fill under the segment
                if color == PRIMARY_COLOR:
                    ax.fill_between(
                        x_arr[run],
                        y_arr[run],
                        color=PRIMARY_COLOR,
                        alpha=0.12,
                        zorder=zorder - 1
                    )

        # Plot secondary (outside selected range) first, then primary on top.
        _plot_segments(x_plot, secondary_y, SECONDARY_COLOR, zorder=2)
        _plot_segments(x_plot, primary_y, PRIMARY_COLOR, zorder=3)

        # Mark boundaries / current time
        if cutoff_datetime is not None:
            ax.axvline(current_md, color=SUCCESS_COLOR, linewidth=2, zorder=4)
            ax.axvline(cutoff_md, color=DANGER_COLOR, linewidth=2, zorder=4)
            ax.axvspan(cutoff_md, current_md, alpha=0.08, color=PRIMARY_COLOR, zorder=0)
        else:
            ax.axvline(
                mdates.date2num(current_datetime),
                color=SUCCESS_COLOR,
                linewidth=2,
                zorder=4
            )

        # Axis formatting — fewer, less-dense ticks
        ax.xaxis.set_major_locator(mdates.AutoDateLocator(maxticks=5))
        ax.xaxis.set_major_formatter(mdates.DateFormatter(fmt))
        plt.setp(ax.get_xticklabels(), color=FOREGROUND_COLOR)

        ax.tick_params(colors=FOREGROUND_COLOR, labelsize=16)
        ax.spines['bottom'].set_color(FOREGROUND_COLOR)
        ax.spines['left'].set_color(FOREGROUND_COLOR)
        ax.spines['top'].set_visible(False)
        ax.spines['right'].set_visible(False)

        ax.set_ylim(bottom=0)
        ax.yaxis.set_major_locator(plt.MaxNLocator(
            nbins=4,
            integer=True,
            prune="both",
            min_n_ticks=2
        ))

        # Title includes selected duration and total count
        total_count = len(feedback_data)
        selected_count: int = sum(
            1 for md in md_dates if cutoff_md is None or (cutoff_md <= md <= current_md)
        )
        duration_str = format_duration(selected_duration)
        ax.set_title(
            # pylint: disable=C0301 # (line-too-long)
            label=f"{FEEDBACK_REVIEW['timeline'][language_code]} ({duration_str}) - {selected_count}/{total_count} {FEEDBACK_REVIEW['responses'][language_code]}:",
            fontsize=18,
            fontweight="bold",
            color=FOREGROUND_COLOR,
            pad=20,
            loc='left'
        )

        ax.grid(axis="y", color=FOREGROUND_COLOR, alpha=0.3, linestyle="--")

    # Adjust layout
    plt.tight_layout()

    # Save to bytes buffer
    buf = io.BytesIO()
    plt.savefig(buf, format='png', dpi=150, bbox_inches='tight')
    buf.seek(0)
    plt.close(fig)

    # Create Discord file
    return File(buf, filename='feedback_timeline.png')

Data Model

The visualizations are based on the following typed dictionary structure retrieved from the SQLite database:

src.util.typed_dicts.FeedbackReviewDict

Bases: TypedDict

Defines the shape of the stored Feedback review data.