Skip to content

Feature Views

These views implement specific user interactions and workflows. They often combine multiple UI components like Buttons, Select Menus, and Modals.

sequenceDiagram
    User->>Discord: /module Mathematic
    Discord->>Oscar: Interaction Event
    Oscar->>ModulSearch: search_module()
    ModulSearch->>Module: from_name("Mathematic M1")
    Module->>Database: Query or API fetch
    Database-->>Module: Module Object
    Module-->>ModulSearch: Return
    ModulSearch->>ModuleView: Initialize with module
    ModuleView-->>User: Interactive Card

1. Onboarding (StartView)

The initial onboarding screen (/start) where users select their language, semester, and course of study.

Key Logic: * Multi-Step Process: The view dynamically rebuilds itself (_build_view) depending on whether language, semester, or major is already set in the state. * Semester Groups: Uses SelectButton instances grouped by "Semester". Clicking one updates the user's preference in the database immediately. * Persistence: Directly interacts with the preferences table via db.set_preferences to store the user's major and language.

src.oscar.ui.start_view.StartView

StartView(user_id: int, semester: int)

Bases: OwnerOnly, LayoutView

Class for the initial start view for the discord bot

Source code in src/oscar/ui/start_view.py
def __init__(self,user_id:int, semester: int):
    super().__init__(timeout=300)
    self.semester: int = semester
    self.user_id: int = user_id
    self.language: LanguageCode = get_user_language(user_id)
    self._build_view()

_build_view

_build_view()
Source code in src/oscar/ui/start_view.py
def _build_view(self):  # pylint: disable=too-many-locals,too-many-statements

    _ = self.clear_items()

    options: list[discord.SelectOption] = []
    for course in StudyCourse.values():
        options.append(
            discord.SelectOption(
                label = STUDY_COURSES[StudyCourse(course)][self.language],
                value = StudyCourse(course).name
            )
        )

    study_course: Select[LayoutView] = Select(
        options=options,
        placeholder="Make a selection" if self.language==LanguageCode.EN else
                    "Triff eine Auswahl",
    )

    async def callback(interaction: discord.Interaction):
        user_id= interaction.user.id
        major = study_course.values[0]
        db = get_database()
        db.set_preferences(user_id = user_id, major = major)
        if self.language == LanguageCode.DE:
            message = f"✅ Studiengang **{major}** wurde gespeichert."
        else:
            message = f"✅ Course of study **{major}** was saved."
        _ = await interaction.response.send_message(message,ephemeral=True)

    study_course.callback = callback
    # hier für die PO
    options_po: list[discord.SelectOption] = []
    for po in PO.values():
        options_po.append(
            discord.SelectOption(
                label=PO_REGULATIONS[PO(po)][self.language],
                value=PO(po).name
            )
        )

    po_select: Select[LayoutView] = Select(
        options=options_po,
        placeholder="Choose examination regulations" if self.language == LanguageCode.EN else
                    "Wähle Prüfungsordnung",
    )

    async def po_callback(interaction: discord.Interaction):
        user_id = interaction.user.id
        po = po_select.values[0]
        db = get_database()
        db.set_preferences(user_id=user_id, spo=po)
        if self.language == LanguageCode.DE:
            message = f"✅ Prüfungsordnung **{po}** wurde gespeichert."
        else:
            message = f"✅ Examination regulations **{po}** were saved."
        _ = await interaction.response.send_message(message, ephemeral=True)

    po_select.callback = po_callback

    yes_button: Button[LayoutView] = discord.ui.Button(
        label="Yes" if self.language==LanguageCode.EN else "Ja",
        style=discord.ButtonStyle.success
    )
    no_button: Button[LayoutView] = discord.ui.Button(
        label="No"if self.language==LanguageCode.EN else "Nein",
        style=discord.ButtonStyle.danger
    )
    en_button: Button[LayoutView] = discord.ui.Button(
        label = LANGUAGES[LanguageCode.EN][LanguageCode.EN],
        style=discord.ButtonStyle.secondary
    )
    de_button: Button[LayoutView] = discord.ui.Button(
        label = LANGUAGES[LanguageCode.DE][LanguageCode.DE],
        style=discord.ButtonStyle.secondary
    )

    async def yes_callback(interaction: discord.Interaction):
        user_id = interaction.user.id
        semester = self.semester
        if semester < 1:
            # no "Semester N" role found, saving -1 would break /semesterplan later
            if self.language == LanguageCode.DE:
                message = "❌ Ich habe keine Semester-Rolle bei dir gefunden. " \
                          "Hol dir bitte zuerst eine."
            else:
                message = "❌ I couldn't find a semester role on your profile. " \
                          "Please pick one first."
            _ = await interaction.response.send_message(message, ephemeral=True)
            return
        db = get_database()
        db.set_preferences(user_id=user_id,semester=semester)
        if self.language == LanguageCode.DE:
            message = f"✅ Semester **{self.semester}** wurde gespeichert."
        else:
            message = f"✅ Semester **{self.semester}** was saved."
        _ = await interaction.response.send_message(message,ephemeral=True)


    async def no_callback(interaction: discord.Interaction):
        _ = self.clear_items()
        _ = self.add_item(
        Container(
            TextDisplay(t(
                self.language,
                "welcome_title",
                START_TEXTS
            )),
            TextDisplay("\u200b"),
            TextDisplay(t(
                self.language,
                "welcome_text",
                START_TEXTS
            )),
            Separator(),
            TextDisplay(t(
                self.language,
                "before_info",
                START_TEXTS
            )),
            TextDisplay("\u200b"),
            TextDisplay(t(
                self.language,
                "language_question",
                START_TEXTS
            )),
            ActionRow[LayoutView](en_button, de_button),
            TextDisplay("\u200b"),
            TextDisplay("**What Semester are you currently in ?**" \
                 if self.language==LanguageCode.EN else "In welchem Semester bist du gerade?"),
            ActionRow(
                SelectButton("1",group="Semester"),
                SelectButton("2",group="Semester"),
                SelectButton("3",group="Semester"),
                SelectButton("4",group="Semester")
            ),
            ActionRow(
                SelectButton("5",group="Semester"),
                SelectButton("6",group="Semester"),
                SelectButton("7",group="Semester"),
                SelectButton("8+",group="Semester")
            ),
            Separator(),
            TextDisplay(t(
                self.language,
                "subject",
                START_TEXTS
            )),
            ActionRow[LayoutView](study_course),
            ActionRow[LayoutView](po_select),
            Separator(),
            TextDisplay(t(
                self.language,
                "commands_info",
                START_TEXTS
            )),
            TextDisplay(self._command_overview()),
            Separator(),
            TextDisplay(t(
                self.language,
                "thank_you",
                START_TEXTS
            )),
            TextDisplay("\u200b"),
            accent_color=0x4378d5)
        )
        _ = await interaction.response.edit_message(view =self)

    async def en_callback(interaction: discord.Interaction):
        user_id = interaction.user.id
        db = get_database()
        db.set_preferences(user_id=user_id, language=LanguageCode.EN)
        self.language = LanguageCode.EN
        self._build_view()
        _ = await interaction.response.edit_message(view=self)

    async def de_callback(interaction: discord.Interaction):
        user_id = interaction.user.id
        db = get_database()
        db.set_preferences(user_id=user_id, language=LanguageCode.DE)
        self.language = LanguageCode.DE
        self._build_view()
        _ = await interaction.response.edit_message(view=self)

    yes_button.callback = yes_callback
    no_button.callback = no_callback
    en_button.callback = en_callback
    de_button.callback = de_callback

    # Fester Anfang
    #self.add_item(

    _ = self.clear_items()

    _ = self.add_item(
        Container[LayoutView](
            TextDisplay(t(
                self.language,
                "welcome_title",
                START_TEXTS
            )),
            TextDisplay("\u200b"),
            TextDisplay(t(
                self.language,
                "welcome_text",
                START_TEXTS
            )),
            Separator(),
            TextDisplay(t(
                self.language,
                "before_info",
                START_TEXTS
            )),
            TextDisplay("\u200b"),
            TextDisplay(t(
                self.language,
                "language_question",
                START_TEXTS
            )),
            ActionRow[LayoutView](en_button, de_button),
            TextDisplay("\u200b"),
            TextDisplay(t(
                self.language,
                "semester_question",
                START_TEXTS
            ).format(semester=self.semester)),
            ActionRow[LayoutView](yes_button, no_button),
            Separator(),
            TextDisplay(t(
                self.language,
                "subject",
                START_TEXTS
            )),
            ActionRow[LayoutView](study_course),
            ActionRow[LayoutView](po_select),
            Separator(),
            TextDisplay(t(
                self.language,
                "commands_info",
                START_TEXTS
            )),
            TextDisplay(self._command_overview()),
            Separator(),
            TextDisplay(t(
                self.language,
                "thank_you",
                START_TEXTS
            )),
            TextDisplay("\u200b"),
            accent_color=0x4378d5

        )
    )

2. Module Interactions

Module Summary (ModuleView)

Displays a summary card of a specific module. It serves as the hub for further actions.

  • Add to Plan: Adds the module to the user's semester plan via db.add_to_semesterplan.
  • Detailed Info: Swaps the current message content with an AllInfoView.
  • LSF: A link button into the public LSF course search, built by util.lsf.
  • Handbook: A link button into the faculty module handbook, built by util.bookstack. It points into the reader's own programme when we know it.

src.oscar.ui.module_view.ModuleView

ModuleView(user_id: int, module: Module)

Bases: LayoutView

Class for displaying module related information

Source code in src/oscar/ui/module_view.py
def __init__(
    self,
    user_id: int,
    module: Module):
    super().__init__()
    self.module: Module = module
    self.language: LanguageCode = get_user_language(user_id)

    # The handbook link points into the users own programme when we know it.
    preferences = get_database().get_preferences(user_id)
    self.study_course: StudyCourse | None = (
        preferences.get("major") if preferences else None
    )

    self._build_view()

Comparison (CompareView)

The UI for /compare. It puts two or three modules next to each other so a student can pick one elective out of several without opening three cards.

  • Layout: One inline embed field per module. Discord renders three of them in a row on a wide screen and stacks them on a phone, which is why MAX_MODULES is 3 rather than a round number.
  • Rows: ROW_KEYS drives both the values and the headings, so a row cannot be added without a translated heading for it.
  • Footer: differing_rows names the rows the modules disagree on. That is the reason for the comparison, so it is said out loud instead of being left to the eye.
  • Limits: Titles and values are cut to discord's 256 and 1024 characters. Going over makes the whole message fail rather than look untidy.

src.oscar.ui.compare_view.CompareView

CompareView(user_id: int, modules: list[Module])

Bases: OwnerOnly, View

Two or three modules side by side, with a handbook link under each.

Source code in src/oscar/ui/compare_view.py
def __init__(self, user_id: int, modules: list[Module]):
    super().__init__(timeout=300)

    self.user_id: int = user_id
    self.language: LanguageCode = get_user_language(user_id)
    self.modules: list[Module] = modules[:MAX_MODULES]

    preferences = get_database().get_preferences(user_id)
    self.study_course: StudyCourse | None = (
        preferences.get("major") if preferences else None
    )

    for module in self.modules:
        _ = self.add_item(
            Button(
                label=_shorten(module.get_title(self.language), 40),
                style=discord.ButtonStyle.link,
                url=module_url(
                    module.id_,
                    module.get_title(self.language),
                    self.study_course,
                ),
            )
        )
create_embed
create_embed() -> discord.Embed

Builds the comparison itself.

Returns:

Type Description
Embed

An embed with one inline field per module and a closing line naming

Embed

what differs.

Source code in src/oscar/ui/compare_view.py
def create_embed(self) -> discord.Embed:
    """ Builds the comparison itself.

        Returns:
            An embed with one inline field per module and a closing line naming
            what differs.
    """
    rows: list[dict[str, str]] = [
        module_rows(module, self.language) for module in self.modules
    ]

    embed = discord.Embed(
        title=t(self.language, "title", COMPARE_TEXTS),
        color=discord.Color.dark_blue(),
    )

    for module, row in zip(self.modules, rows):
        lines: list[str] = [
            f"**{t(self.language, key, COMPARE_TEXTS)}**\n{row[key]}"
            for key in ROW_KEYS
        ]
        _ = embed.add_field(
            name=_shorten(module.get_title(self.language), MAX_FIELD_NAME),
            value=_shorten("\n".join(lines), MAX_FIELD_VALUE),
            inline=True,
        )

    differing: list[str] = differing_rows(rows)
    if differing:
        named: str = ", ".join(
            t(self.language, key, COMPARE_TEXTS) for key in differing
        )
        embed.set_footer(
            text=f"{t(self.language, 'differs', COMPARE_TEXTS)} {named}"
        )
    else:
        embed.set_footer(text=t(self.language, "identical", COMPARE_TEXTS))

    return embed

src.oscar.ui.compare_view.module_rows

module_rows(module: Module, language: LanguageCode) -> dict[str, str]

The values this module contributes to the comparison, keyed by ROW_KEYS.

Parameters:

Name Type Description Default
module Module

The module to read.

required
language LanguageCode

The language the student picked.

required

Returns:

Type Description
dict[str, str]

One cleaned, single line value per row key.

Source code in src/oscar/ui/compare_view.py
def module_rows(module: Module, language: LanguageCode) -> dict[str, str]:
    """ The values this module contributes to the comparison, keyed by `ROW_KEYS`.

        Parameters:
            module: The module to read.
            language: The language the student picked.

        Returns:
            One cleaned, single line value per row key.
    """
    return {
        "credit_points": _clean(module.credit_points),
        "sws": _clean(module.get_teaching_form_sws(language)),
        "language": _clean(module_language_name(module.language, language)),
        "exam": _clean(module.get_study_exam_type(language)),
        "lecturer": _clean(module.lecturer),
    }

src.oscar.ui.compare_view.differing_rows

differing_rows(rows: list[dict[str, str]]) -> list[str]

The row keys on which the modules do not agree.

That is the whole reason for putting them side by side, so it is worth saying out loud rather than leaving the reader to spot it.

Parameters:

Name Type Description Default
rows list[dict[str, str]]

One module_rows result per module.

required

Returns:

Type Description
list[str]

The differing keys, in ROW_KEYS order. Empty when everything matches.

Source code in src/oscar/ui/compare_view.py
def differing_rows(rows: list[dict[str, str]]) -> list[str]:
    """ The row keys on which the modules do not agree.

        That is the whole reason for putting them side by side, so it is worth saying
        out loud rather than leaving the reader to spot it.

        Parameters:
            rows: One `module_rows` result per module.

        Returns:
            The differing keys, in `ROW_KEYS` order. Empty when everything matches.
    """
    return [
        key for key in ROW_KEYS
        if len({row[key] for row in rows}) > 1
    ]

Detailed Information (AllInfoView)

Displays the full data of a module (Credits, Lecturer, Content, Learning Goals). It uses the Module class methods (e.g., get_content(language)) to fetch localized strings, potentially triggering on-the-fly translation if the requested language is missing in the database.

src.oscar.ui.all_info_view.AllInfoView

AllInfoView(user_id: int, module: Module)

Bases: LayoutView

Class representing a detailed view of module information in a Discord UI.

Source code in src/oscar/ui/all_info_view.py
def __init__(self,
    user_id:int,
    module: Module
    ):
    super().__init__()
    self.module: Module = module
    self.user_id: int = user_id
    self.language: LanguageCode = get_user_language(user_id)
    logger.debug(f"Language: '{self.language.name}'")
    self.build_view()

build_view

build_view()

Builds the detailed module information view.

Source code in src/oscar/ui/all_info_view.py
def build_view(self):
    """ Builds the detailed module information view."""
    _ = self.clear_items()

    _ = self.add_item(
        Container[LayoutView](
            TextDisplay(f"# {self.module.get_title(self.language)}"),
            Separator(),
            TextDisplay(
                f"_{INFO_TEXTS['module_number'][self.language]}: {self.module.id_}_"
            ),
            Separator(),
            TextDisplay(f"**{INFO_TEXTS['responsibility'][self.language]}**"),
            TextDisplay(f"{self.module.responsibility}"),
            Separator(),
            TextDisplay(f"**{INFO_TEXTS['lecturer'][self.language]}**"),
            TextDisplay(f"{self.module.lecturer}"),
            ActionRow[LayoutView](
                Button(
                    label=f"{INFO_TEXTS['abbreviation'][self.language]}:  {
                        self.module.abbreviation}"
                ),
                Button(label=f"CP: {self.module.credit_points}"),
                Button(label=f"Semester: {self.module.get_semester_position()}"),
                Button(
                    label=f"{INFO_TEXTS['semester'][self.language]}: {
                        self.module.get_academic_semester()}"
                ),
            ),
            ActionRow[LayoutView](
                Button(
                    label=f"{INFO_TEXTS['duration'][self.language]}: {
                        self.module.get_duration()}"
                ),
                Button(
                    label=f"{INFO_TEXTS['language'][self.language]}:  {
                        module_language_name(self.module.language, self.language)}"
                ),
                Button(label=f"{INFO_TEXTS['level'][self.language]}: {
                    ', '.join(self.module.get_level())}"
                ),
            ),
            Separator(),
            TextDisplay(f"**{INFO_TEXTS['ilo'][self.language]}**"),
            TextDisplay(f"{self.module.get_learning_goals(self.language)}"),
            Separator(),
            TextDisplay(f"**{INFO_TEXTS['content'][self.language]}**"),
            TextDisplay(f"{self.module.get_content(self.language)}"),
            Separator(),
            TextDisplay(f"**{INFO_TEXTS['effort'][self.language]}**"),
            TextDisplay(f"{self.module.get_workload(self.language)}"),
            Separator()
        )
    )

3. Semester Plan (SemesterplanView)

Shows the user's personal semester plan.

Technical Specifics: * Inheritance: Inherits from CustomView to support external rebuild triggers. * Dynamic Rebuild: The build_view() method fetches the fresh module list from the database every time it runs. * Interaction: Uses DeleteButton, which calls self.view_ref.build_view() on this view to refresh the UI immediately after a module is removed.

src.oscar.ui.semesterplan_view.SemesterplanView

SemesterplanView(user_id: int)

Bases: CustomView

Custom class to hold a semester plan (view)

Source code in src/oscar/ui/semesterplan_view.py
def __init__(self, user_id: int):
    super().__init__(user_id=user_id)

build_view

build_view()
Source code in src/oscar/ui/semesterplan_view.py
@override
def build_view(self):  # pylint: disable=too-many-locals,too-many-branches,too-many-statements
    _ = self.clear_items()
    db = get_database()
    module_list: list[Module] = db.get_semesterplan(self.user_id)
    preferences: PrefsDict | None = db.get_preferences(self.user_id)

    semester = preferences.get("semester") if preferences else None
    major: StudyCourse | None = preferences.get("major") if preferences else None
    spo = preferences.get("spo") if preferences else None
    winter = preferences.get("winter_semester") if preferences else None

    logger.debug(
        f"Building semesterplan view: spo={spo!r}, winter={winter!r}, "
        f"major={major!r}, semester={semester!r}"
    )

    language: LanguageCode = LanguageCode.DE
    if preferences is not None:
        language = preferences["language"]

    plan: Container[LayoutView] = Container[LayoutView](
        TextDisplay("# Deine Semesterübersicht" if language == LanguageCode.DE
                    else "# Your Semester Overview"),
        Separator()
    )


    full_module_list: list[Module] = []
    for m in module_list:
        try:
            full_module_list.append(Module.from_id(m.id_))
        except IndexError:
            continue

    acquired_cp = 0
    for m in full_module_list:
        acquired_cp += int(m.credit_points) if m.credit_points.strip() else 0
    _ = plan.add_item(TextDisplay(
        "## Deine eingetragenen Module" if language == LanguageCode.DE
        else "## Your Enrolled Modules"
    ))

    if not full_module_list:
        _ = plan.add_item(TextDisplay(
            "Noch keine Module eingetragen." if language == LanguageCode.DE
            else "No modules added yet."
        ))
    elif len(full_module_list) <= ROWS_MAX:
        for m in full_module_list:
            cp_str = m.credit_points.strip() if m.credit_points.strip() else "?"
            text_section = TextDisplay(f"{m.get_title(language)} – {cp_str} CP")
            original = next((orig for orig in module_list if orig.id_ == m.id_), m)
            button_section: ActionRow[LayoutView] = ActionRow[LayoutView](
                InfoButton(
                    label="mehr Infos" if language == LanguageCode.DE else "more info",
                    module=f"{m.get_title(language)}"
                ),
                DeleteButton(view=self, module=original)
            )
            # no separator in between, it is the fifth component of a module and
            # the one that pushed a plan of six over the limit
            _ = plan.add_item(text_section)
            _ = plan.add_item(button_section)
        _ = plan.add_item(Separator())
    else:
        self._add_long_plan(plan, full_module_list, language)

    cp_label = (f"**Gesamt CP: {acquired_cp}**" if language == LanguageCode.DE
                else f"**Total CP: {acquired_cp}**")
    _ = plan.add_item(TextDisplay(cp_label))
    _ = plan.add_item(
        ActionRow[LayoutView](
            ExportCalendarButton(user_id=self.user_id, language=language)
        )
    )
    _ = plan.add_item(Separator())


    if major is None or spo is None or semester is None:
        missing = (
            " Bitte nutze zuerst /start und wähle Studiengang, Prüfungsordnung "
            "und Semester aus."
            if language == LanguageCode.DE else
            " Please run /start first and pick your programme, examination "
            "regulations and semester."
        )
        _ = plan.add_item(TextDisplay(missing))
        _ = self.add_item(plan)
        return

    # Every plan we ship is offered. The manager falls back to the closest SPO,
    # so a hardcoded list of supported programmes would only hide our own data.
    try:
        rsp_modules: list[dict] = get_modules_for_semester(
            spo=spo,
            major=major,
            winter=winter is not False,
            semester=semester,
            study_course=major,
        )
    except FileNotFoundError:
        warning = (
            " Für deine SPO, deinen Studiengang oder dein Startsemester "
            "existiert noch kein Regelstudienplan."
            if language == LanguageCode.DE else
            " No study progression plan available for your SPO, major, or start semester yet."
        )
        _ = plan.add_item(TextDisplay(warning))
        _ = self.add_item(plan)
        return
    _ = plan.add_item(TextDisplay(
        f"## Regelstudienplan – {semester}. Semester" if language == LanguageCode.DE
        else f"## Study Progression Plan – Semester {semester}"
    ))

    if not rsp_modules:
        _ = plan.add_item(TextDisplay(
            "Keine Module für dieses Semester gefunden." if language == LanguageCode.DE
            else "No modules found for this semester."
        ))
    else:
        # one block of text, a component per line is what the limit is spent on
        rsp_lines: list[str] = []
        for rsp_module in rsp_modules:
            rsp_id = rsp_module.get("identification")
            rsp_title = rsp_module.get("title", "?")

            if rsp_id is None:

                rsp_usability: list[int] = rsp_module.get("usability", [])
                covered = any(
                    any(uid in rsp_usability for uid in get_usability_for_major(full_m, major))
                    for full_m in full_module_list
                )
            else:

                covered = any(full_m.id_ == rsp_id for full_m in full_module_list)

            emoji = "✅" if covered else "❌"
            rsp_lines.append(f"{emoji} {rsp_title}")
        _ = plan.add_item(TextDisplay("\n".join(rsp_lines)))

    _ = plan.add_item(Separator())
    _ = self.add_item(plan)

4. Search & Filter

Filter Selection (SelectView)

The UI for the /filter command. It allows users to combine criteria like CP, SWS, and exam type.

  • Logic: It maps button labels (e.g., "<5CP") to CpFilter and SwsFilter Enums.
  • Execution: Upon clicking "Search", it instantiates ModulesFilter to process the criteria and transitions to the PaginatedModuleView with the results.

src.oscar.ui.select_view.SelectView

SelectView(user_id: int)

Bases: OwnerOnly, LayoutView

Class for filtering a list of modules

Source code in src/oscar/ui/select_view.py
def __init__(self,user_id:int):
    super().__init__()
    # kept so `OwnerOnly` can tell whose filter mask this is
    self.user_id: int = user_id
    self.language: LanguageCode = get_user_language(user_id)
    self._build_view()

_build_view

_build_view()

Draws the whole mask.

Source code in src/oscar/ui/select_view.py
def _build_view(self):
    """ Draws the whole mask."""
    search_button: Button[LayoutView] = discord.ui.Button(
        label=self._text("search"), style=discord.ButtonStyle.primary
    )
    search_button.callback = self._search

    _ = self.clear_items()
    _ = self.add_item(
        Container(
            TextDisplay(
                f"# {self._text('title')}\n{self._text('hint')}"
            ),
            Separator(),
            *self._option_rows(),
            ActionRow(search_button),
            accent_color=ACCENT_COLOR,
        )
    )

Filter Results (PaginatedModuleView)

Displays the search/filter results with navigation buttons (<<, <, >, >>). It manages pagination logic (index, per_page) locally and updates the Embed content dynamically.

src.oscar.ui.select_view.PaginatedModuleView

PaginatedModuleView(modules: list, user_id, per_page: int = 6)

Bases: OwnerOnly, LayoutView

Class for showing a filtered module list page by page, in a chosen order.

Source code in src/oscar/ui/select_view.py
def __init__(self, modules: list, user_id, per_page: int = 6 ):
    super().__init__()
    # the order the filter produced, kept so the sorting can be taken back
    self.modules: list[Module] = list(modules)
    self.per_page = per_page
    self.index = 0
    self.total_pages = max(1, (len(self.modules) + per_page - 1) // per_page)
    self.user_id = user_id
    self.language: LanguageCode = get_user_language(user_id)
    self.sort_key: SortKey | None = None
    self.sort_descending: bool = False
    # what the pages are cut out of, so the order holds across every page
    self.ordered_modules: list[Module] = list(self.modules)

    self._build_page()

_build_page

_build_page()
Source code in src/oscar/ui/select_view.py
def _build_page(self):
    _ = self.clear_items()

    header: str = self._text("page_header").format(
        page=self.index + 1, pages=self.total_pages
    )
    page_container = Container(
        TextDisplay(f"# {header}"),
        Separator(),
        accent_color=ACCENT_COLOR,
    )

    if not self.modules:
        _ = page_container.add_item(TextDisplay(self._text("no_results")))
        _ = self.add_item(page_container)
        return

    for position, module in enumerate(self._get_page_modules()):
        # A `Separator` between the results cost one component each, and six of
        # them put the page at 37 of 40. `per_page=7` then raised
        # `ValueError: maximum number of children exceeded (40)`, and `per_page`
        # is a constructor argument anybody may tune. The blank line does the
        # same job for free.
        spacing: str = GROUP_SPACING if position else ""
        _ = page_container.add_item(
            TextDisplay(f"{spacing}**{module.get_title(self.language)}**")
        )
        _ = page_container.add_item(
            ActionRow(
                InfoButton(
                    label=self._text("more_info"),
                    # InfoButton looks the module up by name and has no language
                    # flag, so it always gets the German title.
                    module=module.get_title(LanguageCode.DE),
                )
            )
        )

    _ = self.add_item(page_container)
    _ = self.add_item(self._sort_row())
    _ = self.add_item(self._nav_row())

_next async

_next(interaction: Interaction)
Source code in src/oscar/ui/select_view.py
async def _next(self, interaction: discord.Interaction):
    self.index += 1
    self._build_page()
    _ = await interaction.response.edit_message(view=self)

_prev async

_prev(interaction: Interaction)
Source code in src/oscar/ui/select_view.py
async def _prev(self, interaction: discord.Interaction):
    self.index -= 1
    self._build_page()
    _ = await interaction.response.edit_message(view=self)

5. User Feedback (FeedbackView)

A complex form allowing users to submit ratings and text feedback.

Components: * Select Menus: Three dropdowns for scoring (Intuitiveness, Discoverability, Usefulness). The code maps values (1-4) inversely to the database storage (where 4 might represent the best or worst score depending on the specific scale logic). * Modals: Buttons trigger the ImproveModal to collect text input (Improvements, Wishes, Bugs).

src.oscar.ui.feedback_view.FeedbackView

FeedbackView(user_id: int, semester: int)

Bases: OwnerOnly, LayoutView

Class for giving feedback

Source code in src/oscar/ui/feedback_view.py
def __init__(self,user_id:int, semester: int):
    super().__init__(timeout=300)
    self.semester: int = semester
    self.user_id: int = user_id
    self.language: LanguageCode = get_user_language(user_id)
    self._build_view()
    self.intuitiveness: int | None = None
    self.discoverability: int | None = None
    self.usefulness: int | None = None

    self.improvements: str = ""
    self.wishes: str = ""
    self.bugs: str = ""

_build_view

_build_view()
Source code in src/oscar/ui/feedback_view.py
def _build_view(self):  # pylint: disable=too-many-locals

    #---- 3 selecter-------
    options = [
        discord.SelectOption(
            label=t(self.language,"choose_1",FEEDBACK_TEXTS),value="1"
        ),
        discord.SelectOption(
            label=t(self.language,"choose_2",FEEDBACK_TEXTS),value="2"
        ),
        discord.SelectOption(
            label=t(self.language,"choose_3",FEEDBACK_TEXTS),value="3"
        ),
        discord.SelectOption(
            label=t(self.language,"choose_4",FEEDBACK_TEXTS),value="4"
        )
    ]

    intuative: Select[LayoutView] = Select(
        options=options,
        placeholder="The operation is intuitive" if self.language==LanguageCode.EN else
                    "Die Bedienung ist intuitiv.",
    )

    modules: Select[LayoutView] = Select(
        options=options,
        placeholder="Modules are easy to find an plan." if self.language==LanguageCode.EN else
                    "Module lassen sich gut finden und planen.",
    )

    info: Select[LayoutView] = Select(
        options=options,
        placeholder="The information displayed is useful" if self.language==LanguageCode.EN else
                    "Die zu sehenden Informationen sind sinnvoll.",
    )

    async def intuitiveness_callback(interaction: discord.Interaction):
        self.intuitiveness = int(intuative.values[0])
        _ = await interaction.response.defer()

    intuative.callback = intuitiveness_callback

    async def discoverability_callback(interaction: discord.Interaction):
        self.discoverability = int(modules.values[0])
        _ = await interaction.response.defer()

    modules.callback = discoverability_callback


    async def usefulness_callback(interaction: discord.Interaction):
        self.usefulness = int(info.values[0])
        _ = await interaction.response.defer()

    info.callback = usefulness_callback



    #------ buttons for modals-----
    improve_button: Button[LayoutView] = discord.ui.Button(
        label="What can be improved?" if self.language==LanguageCode.EN else
              "Was kann verbessert werden?",
        style=discord.ButtonStyle.secondary
    )
    wish_button: Button[LayoutView] = discord.ui.Button(
        label="What is something you wish for?" if self.language==LanguageCode.EN else
              "Was wünscht ihr euch noch?",
        style=discord.ButtonStyle.secondary
    )
    bug_button: Button[LayoutView] = discord.ui.Button(
        label="Have you found any busg?" if self.language==LanguageCode.EN else
              "Hast du bugs gefunden?",
        style=discord.ButtonStyle.secondary
    )

    async def improve_callback(interaction: discord.Interaction):
        modal = ImproveModal(
            parent=self,
            language=self.language,
            title="Improvement" if self.language==LanguageCode.EN else "Verbesserung",
            number=1
        )

        _ = await interaction.response.send_modal(modal)

    improve_button.callback = improve_callback

    async def wish_callback(interaction: discord.Interaction):
        modal = ImproveModal(
        parent=self,
        language=self.language,
        title="wishes" if self.language==LanguageCode.EN else "Wünsche",
        number=2
    )
        _ = await interaction.response.send_modal(modal)

    wish_button.callback = wish_callback

    async def bug_callback(interaction: discord.Interaction):
        modal = ImproveModal(
        parent=self,
        language=self.language,
        title="bug",
        number=3
    )
        _ = await interaction.response.send_modal(modal)

    bug_button.callback = bug_callback

    #-----send button-----
    send_button: Button[LayoutView] = discord.ui.Button(
        label="Send" if self.language==LanguageCode.EN else "Senden",
        style=discord.ButtonStyle.success
    )
    async def send_callback(interaction: discord.Interaction):
        db = get_database()
        db.send_feedback(
            user_id=self.user_id,
            intuitiveness=5-(self.intuitiveness or 4),      # reverse scale beacause of dropdown
            discoverability=5-(self.discoverability or 4),  # option ordering
            usefulness=5-(self.usefulness or 4),
            improvements=self.improvements,
            wishes=self.wishes,
            bugs=self.bugs
        )

        _ = await interaction.response.send_message(
            "Danke für dein Feedback!",
            ephemeral=True)


    send_button.callback = send_callback

    _ = self.clear_items()
    #-----window-----
    _ = self.add_item(
        Container(
            TextDisplay("# Feedback"),
            TextDisplay(t(self.language,"welcome_text",FEEDBACK_TEXTS)),
            Separator(),
            ActionRow[LayoutView](intuative),
            ActionRow[LayoutView](modules),
            ActionRow[LayoutView](info),
            Separator(),
            ActionRow[LayoutView](improve_button),
            ActionRow[LayoutView](wish_button),
            ActionRow[LayoutView](bug_button),
            Separator(),
            ActionRow[LayoutView](send_button)
        )
    )

Feedback Modal

Helper class (discord.ui.Modal) used by FeedbackView to capture multi-line text input.

src.oscar.ui.feedback_view.ImproveModal

ImproveModal(parent: FeedbackView, language: LanguageCode, title: str, number: int)

Bases: Modal

Source code in src/oscar/ui/feedback_view.py
def __init__(self,parent: "FeedbackView", language: LanguageCode, title: str, number: int):
    super().__init__(title=title)
    self.parent: FeedbackView = parent
    self.number: int = number
    label: str = ""
    if number == 1:
        label = t(language, "improve", FEEDBACK_TEXTS)
    elif number == 2:
        label = t(language, "wish", FEEDBACK_TEXTS)
    elif number == 3:
        label = t(language, "bug", FEEDBACK_TEXTS)
    self.answer: TextInput[LayoutView] = ui.TextInput(
        label=label,
        style=discord.TextStyle.long,
        required=False
    )

    _ = self.add_item(self.answer)

6. Standard Study Plan (SPlanView)

The UI for /standard_plan. Unlike the others it uses the classic embed API rather than Components V2, because it carries an image attachment.

  • Picture: Two programmes have a hand drawn image. Every other plan is rendered from its json by util.plan_image.
  • Handbook: A link button into the programme's own book in the faculty BookStack.

src.oscar.ui.splan_view.SPlanView

SPlanView(user_id: int)

Bases: OwnerOnly, View

View for the standard study plan (NO Components V2).

Source code in src/oscar/ui/splan_view.py
def __init__(self, user_id: int):
    super().__init__(timeout=180)

    self.user_id: int = user_id
    self.language: LanguageCode = get_user_language(user_id)

    prefs = get_database().get_preferences(user_id)

    if not prefs or "spo" not in prefs or "major" not in prefs:
        raise ValueError("User has no complete study preferences")

    self.user_major: StudyCourse = prefs["major"]
    self.user_po: PO = PO[prefs["spo"]]
    self.winter: bool = prefs.get("winter_semester", True)

    self.plan: Semesterplan | None = get_semesterplans_manager().get_best_matching_plan(
        study_course=self.user_major,
        spo=_spo_year(self.user_po.name),
        winter_semester=self.winter,
    )

    # A student who saved nothing is shown the plain plan. Marking every box as
    # missing would be a wall of crosses that tells them nothing they can act on.
    self.progress: PlanProgress | None = None
    if self.plan is not None:
        saved = load_saved_modules(user_id)
        if saved:
            self.progress = match_plan(self.plan, saved, self.user_major)

    # The official handbook for this programme, a link button needs no callback.
    _ = self.add_item(
        Button(
            label=(
                "zum Modulhandbuch" if self.language == LanguageCode.DE
                else "to the handbook"
            ),
            style=discord.ButtonStyle.link,
            url=book_url(self.user_major),
        )
    )

create_embed

create_embed() -> discord.Embed

Builds the embed for the standard study plan.

Source code in src/oscar/ui/splan_view.py
def create_embed(self) -> discord.Embed:
    """ Builds the embed for the standard study plan."""
    is_german = self.language == LanguageCode.DE

    title = "Regelstudienplan" if is_german else "Standard Study Plan"

    if self.plan is None:
        description = (
            f"Für **{self.major_name}** (PO {self.user_po.name[-4:]}) gibt es noch "
            "keinen hinterlegten Regelstudienplan."
            if is_german else
            f"There is no standard study plan for **{self.major_name}** "
            f"(PO {self.user_po.name[-4:]}) yet."
        )
        return discord.Embed(
            title=title, description=description, color=discord.Color.orange()
        )

    embed = discord.Embed(
        title=f"{title} – {self.plan.title}",
        description=(
            f"PO {self.plan.spo} · {len(self.plan.semesters)} "
            + ("Semester" if is_german else "semesters")
        ),
        color=discord.Color.blue(),
    )

    for index in range(len(self.plan.semesters)):
        embed.add_field(
            name=self._semester_label(index),
            value=self._semester_lines(index) or "—",
            inline=False,
        )

    # every plan has a picture now, either hand drawn or rendered
    embed.set_image(url=f"attachment://{IMAGE_FILENAME}")
    embed.set_footer(text=self._footer())

    return embed

get_image_file

get_image_file() -> discord.File | None

The picture that belongs to this plan.

A hand drawn image wins while there is nothing to mark. It is a flat png, so a tick cannot be drawn into it. As soon as the student has saved modules the plan is rendered instead, because a marked text next to an unmarked picture reads as two different plans.

Returns:

Type Description
File | None

A discord.File named studyplan.png, or None when there is no plan.

Source code in src/oscar/ui/splan_view.py
def get_image_file(self) -> discord.File | None:
    """ The picture that belongs to this plan.

        A hand drawn image wins while there is nothing to mark. It is a flat png,
        so a tick cannot be drawn into it. As soon as the student has saved
        modules the plan is rendered instead, because a marked text next to an
        unmarked picture reads as two different plans.

        Returns:
            A `discord.File` named `studyplan.png`, or `None` when there is no plan.
    """
    path = self.get_image_path()
    if path is not None and self.progress is None:
        return discord.File(path, filename=IMAGE_FILENAME)

    if self.plan is None:
        return None

    covered = self.progress.covered if self.progress is not None else None
    return discord.File(
        render_semesterplan(self.plan, self.language, covered),
        filename=IMAGE_FILENAME,
    )