diff --git a/capy_discord/exts/profile/_service.py b/capy_discord/exts/profile/_service.py new file mode 100644 index 0000000..f46119d --- /dev/null +++ b/capy_discord/exts/profile/_service.py @@ -0,0 +1,114 @@ +import logging +from datetime import datetime +from typing import Any, Literal +from zoneinfo import ZoneInfo + +import discord +from pydantic import ValidationError + +from ._schemas import UserProfileDetailsSchema, UserProfileIdentitySchema, UserProfileSchema + + +class ProfileExistsError(Exception): + """Raised when a profile already exists for a create request.""" + + +class ProfileNotFoundError(Exception): + """Raised when a profile is required but does not exist.""" + + +class InvalidProfileError(Exception): + """Raised when combined profile data fails validation.""" + + +class ProfileService: + """Owns profile storage, validation, and presentation helpers.""" + + def __init__(self, bot: discord.Client, log: logging.Logger) -> None: + """Initialize the profile service and shared in-memory store.""" + self.log = log + store: dict[int, UserProfileSchema] | None = getattr(bot, "profile_store", None) + if store is None: + store = {} + setattr(bot, "profile_store", store) # noqa: B010 + self._profiles = store + + def start_edit(self, user_id: int, action: str) -> dict[str, Any] | None: + """Return existing profile data for an edit flow after validating the action.""" + profile = self._profiles.get(user_id) + + if action == "create" and profile: + raise ProfileExistsError + if action == "update" and not profile: + raise ProfileNotFoundError + + return profile.model_dump() if profile else None + + def merge_identity_step(self, user_id: int, identity: UserProfileIdentitySchema) -> dict[str, Any]: + """Combine the first modal step with any existing profile fields.""" + current_profile = self._profiles.get(user_id) + profile_data = current_profile.model_dump() if current_profile else {} + profile_data.update(identity.model_dump()) + return profile_data + + def build_profile( + self, + user: discord.abc.User, + details: UserProfileDetailsSchema, + profile_data: dict[str, Any], + ) -> UserProfileSchema: + """Combine modal steps into a validated profile model.""" + combined_data = {**profile_data, **details.model_dump()} + + try: + return UserProfileSchema(**combined_data) + except ValidationError as error: + self.log.warning("Full profile validation failed for user %s: %s", user, error) + raise InvalidProfileError from error + + def finalize_profile( + self, + user: discord.abc.User, + details: UserProfileDetailsSchema, + profile_data: dict[str, Any], + action: Literal["create", "update"], + ) -> tuple[UserProfileSchema, Literal["created", "updated"]]: + """Validate and persist the final profile payload.""" + profile = self.build_profile(user, details, profile_data) + self.save_profile(user, profile) + result = "created" if action == "create" else "updated" + return profile, result + + def get_profile(self, user_id: int) -> UserProfileSchema: + """Fetch a saved profile or raise when missing.""" + profile = self._profiles.get(user_id) + if not profile: + raise ProfileNotFoundError + return profile + + def save_profile(self, user: discord.abc.User, profile: UserProfileSchema) -> None: + """Persist a validated profile.""" + self._profiles[user.id] = profile + self.log.info("Updated profile for user %s", user) + + def delete_profile(self, user: discord.abc.User) -> None: + """Delete an existing profile.""" + self.get_profile(user.id) + del self._profiles[user.id] + self.log.info("Deleted profile for user %s", user) + + def create_profile_embed(self, user: discord.User | discord.Member, profile: UserProfileSchema) -> discord.Embed: + """Build the profile display embed.""" + embed = discord.Embed(title=f"{user.display_name}'s Profile") + embed.set_thumbnail(url=user.display_avatar.url) + + embed.add_field(name="Name", value=profile.preferred_name, inline=True) + embed.add_field(name="Major", value=profile.major, inline=True) + embed.add_field(name="Grad Year", value=str(profile.graduation_year), inline=True) + embed.add_field(name="Email", value=profile.school_email, inline=True) + embed.add_field(name="Minor", value=profile.minor or "N/A", inline=True) + embed.add_field(name="Description", value=profile.description or "N/A", inline=False) + + now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") + embed.set_footer(text=f"Student ID: *****{profile.student_id[-4:]} • Last updated: {now}") + return embed diff --git a/capy_discord/exts/profile/profile.py b/capy_discord/exts/profile/profile.py index d586293..4f6455e 100644 --- a/capy_discord/exts/profile/profile.py +++ b/capy_discord/exts/profile/profile.py @@ -1,20 +1,23 @@ import logging -from collections.abc import Callable -from datetime import datetime +from collections.abc import Awaitable, Callable from functools import partial -from typing import Any -from zoneinfo import ZoneInfo +from typing import Any, Literal import discord from discord import app_commands, ui from discord.ext import commands -from pydantic import ValidationError from capy_discord.ui.embeds import error_embed, info_embed, success_embed from capy_discord.ui.forms import ModelModal from capy_discord.ui.views import BaseView from ._schemas import UserProfileDetailsSchema, UserProfileIdentitySchema, UserProfileSchema +from ._service import ( + InvalidProfileError, + ProfileExistsError, + ProfileNotFoundError, + ProfileService, +) class ConfirmDeleteView(BaseView): @@ -23,7 +26,7 @@ class ConfirmDeleteView(BaseView): def __init__(self) -> None: """Initialize the ConfirmDeleteView.""" super().__init__(timeout=60) - self.value = None + self.value: bool | None = None @ui.button(label="Delete Profile", style=discord.ButtonStyle.danger) async def confirm(self, interaction: discord.Interaction, _button: ui.Button) -> None: @@ -42,12 +45,28 @@ async def cancel(self, interaction: discord.Interaction, _button: ui.Button) -> self.stop() +class ProfileLaunchButton(ui.Button["ProfileModalLauncherView"]): + """Button that opens the next step of the profile editor.""" + + @property + def launcher_view(self) -> "ProfileModalLauncherView": + """Return the attached launcher view.""" + if self.view is None: + msg = "ProfileLaunchButton must be attached to a ProfileModalLauncherView before use." + raise RuntimeError(msg) + return self.view + + async def callback(self, interaction: discord.Interaction) -> None: + """Delegate button clicks to the parent view callback.""" + await self.launcher_view.open_modal(interaction) + + class ProfileModalLauncherView(BaseView): """Launch the multi-step profile editor from a button.""" def __init__( self, - callback: Callable[[discord.Interaction], Any], + callback: Callable[[discord.Interaction], Awaitable[None]], *, button_label: str = "Open Profile Form", button_emoji: str | None = None, @@ -56,10 +75,9 @@ def __init__( """Initialize the launcher view.""" super().__init__(timeout=300) self._callback = callback - self.add_item(ui.Button(label=button_label, emoji=button_emoji, style=button_style)) - self.children[0].callback = self._button_callback # type: ignore[method-assign] + self.add_item(ProfileLaunchButton(label=button_label, emoji=button_emoji, style=button_style)) - async def _button_callback(self, interaction: discord.Interaction) -> None: + async def open_modal(self, interaction: discord.Interaction) -> None: """Open the first profile modal.""" await self._callback(interaction) @@ -71,12 +89,7 @@ def __init__(self, bot: commands.Bot) -> None: """Initialize the Profile cog.""" self.bot = bot self.log = logging.getLogger(__name__) - # In-memory storage for demonstration, attached to the bot so other cogs can read it. - store: dict[int, UserProfileSchema] | None = getattr(bot, "profile_store", None) - if store is None: - store = {} - setattr(bot, "profile_store", store) # noqa: B010 - self.profiles = store + self.service = ProfileService(bot, self.log) @app_commands.command(name="profile", description="Manage your profile") @app_commands.describe(action="The action to perform with your profile") @@ -102,25 +115,23 @@ async def profile(self, interaction: discord.Interaction, action: str) -> None: async def handle_edit_action(self, interaction: discord.Interaction, action: str) -> None: """Logic for creating or updating a profile.""" - user_id = interaction.user.id - - # [DB CALL]: Fetch profile - current_profile = self.profiles.get(user_id) - - if action == "create" and current_profile: - embed = error_embed( - "Profile Exists", "You already have a profile! Use `/profile action:update` to edit it." + try: + initial_data = self.service.start_edit(interaction.user.id, action) + except ProfileExistsError: + await self._send_error( + interaction, + "Profile Exists", + "You already have a profile! Use `/profile action:update` to edit it.", ) - await interaction.response.send_message(embed=embed, ephemeral=True) return - if action == "update" and not current_profile: - embed = error_embed("No Profile", "You don't have a profile yet! Use `/profile action:create` first.") - await interaction.response.send_message(embed=embed, ephemeral=True) + except ProfileNotFoundError: + await self._send_error( + interaction, + "No Profile", + "You don't have a profile yet! Use `/profile action:create` first.", + ) return - # Convert Pydantic model to dict for initial data if it exists - initial_data = current_profile.model_dump() if current_profile else None - self.log.info("Opening profile modal for user %s (%s)", interaction.user, action) await self._open_profile_identity_modal(interaction, action, initial_data) @@ -143,9 +154,7 @@ async def _handle_profile_identity_submit( self, interaction: discord.Interaction, identity: UserProfileIdentitySchema, action: str ) -> None: """Persist step-one data and offer a button to continue to step two.""" - current_profile = self.profiles.get(interaction.user.id) - profile_data = current_profile.model_dump() if current_profile else {} - profile_data.update(identity.model_dump()) + profile_data = self.service.merge_identity_step(interaction.user.id, identity) view = ProfileModalLauncherView( callback=partial(self._open_profile_details_modal, action=action, profile_data=profile_data), @@ -168,7 +177,7 @@ async def _open_profile_details_modal( """Open the second step of the profile editor.""" modal = ModelModal( model_cls=UserProfileDetailsSchema, - callback=partial(self._handle_profile_details_submit, profile_data=profile_data), + callback=partial(self._handle_profile_details_submit, profile_data=profile_data, action=action), title=f"{action.title()} Your Profile (2/2)", initial_data=profile_data, ) @@ -179,39 +188,42 @@ async def _handle_profile_details_submit( interaction: discord.Interaction, details: UserProfileDetailsSchema, profile_data: dict[str, Any], + action: Literal["create", "update"], ) -> None: """Combine both modal steps into a validated profile.""" - combined_data = {**profile_data, **details.model_dump()} - try: - profile = UserProfileSchema(**combined_data) - except ValidationError as error: - self.log.warning("Full profile validation failed for user %s: %s", interaction.user, error) - embed = error_embed("Profile Validation Failed", "Please restart the profile flow and try again.") - await interaction.response.send_message(embed=embed, ephemeral=True) + profile, result = self.service.finalize_profile(interaction.user, details, profile_data, action) + except InvalidProfileError: + await self._send_error( + interaction, + "Profile Validation Failed", + "Please restart the profile flow and try again.", + ) return - await self._handle_profile_submit(interaction, profile) + await self._handle_profile_submit(interaction, profile, result) async def handle_show_action(self, interaction: discord.Interaction) -> None: """Logic for the 'show' choice.""" - profile = self.profiles.get(interaction.user.id) - - if not profile: - embed = error_embed("No Profile", "You haven't set up a profile yet! Use `/profile action:create`.") - await interaction.response.send_message(embed=embed, ephemeral=True) + try: + profile = self.service.get_profile(interaction.user.id) + except ProfileNotFoundError: + await self._send_error( + interaction, + "No Profile", + "You haven't set up a profile yet! Use `/profile action:create`.", + ) return - embed = self._create_profile_embed(interaction.user, profile) + embed = self.service.create_profile_embed(interaction.user, profile) await interaction.response.send_message(embed=embed) async def handle_delete_action(self, interaction: discord.Interaction) -> None: """Logic for the 'delete' choice.""" - profile = self.profiles.get(interaction.user.id) - - if not profile: - embed = error_embed("No Profile", "You don't have a profile to delete.") - await interaction.response.send_message(embed=embed, ephemeral=True) + try: + self.service.get_profile(interaction.user.id) + except ProfileNotFoundError: + await self._send_error(interaction, "No Profile", "You don't have a profile to delete.") return view = ConfirmDeleteView() @@ -224,42 +236,30 @@ async def handle_delete_action(self, interaction: discord.Interaction) -> None: await view.wait() if view.value is True: - # [DB CALL]: Delete profile - del self.profiles[interaction.user.id] - self.log.info("Deleted profile for user %s", interaction.user) + self.service.delete_profile(interaction.user) embed = success_embed("Profile Deleted", "Your profile has been deleted.") await interaction.followup.send(embed=embed, ephemeral=True) else: embed = info_embed("Cancelled", "Profile deletion cancelled.") await interaction.followup.send(embed=embed, ephemeral=True) - async def _handle_profile_submit(self, interaction: discord.Interaction, profile: UserProfileSchema) -> None: + async def _handle_profile_submit( + self, + interaction: discord.Interaction, + profile: UserProfileSchema, + result: Literal["created", "updated"], + ) -> None: """Process the valid profile submission.""" - # [DB CALL]: Save profile - self.profiles[interaction.user.id] = profile - - self.log.info("Updated profile for user %s", interaction.user) - - embed = self._create_profile_embed(interaction.user, profile) - success = success_embed("Profile Updated", "Your profile has been updated successfully!") + embed = self.service.create_profile_embed(interaction.user, profile) + if result == "created": + success = success_embed("Profile Created", "Your profile has been created successfully!") + else: + success = success_embed("Profile Updated", "Your profile has been updated successfully!") await interaction.response.send_message(embeds=[success, embed], ephemeral=True) - def _create_profile_embed(self, user: discord.User | discord.Member, profile: UserProfileSchema) -> discord.Embed: - """Helper to build the profile display embed.""" - embed = discord.Embed(title=f"{user.display_name}'s Profile") - embed.set_thumbnail(url=user.display_avatar.url) - - embed.add_field(name="Name", value=profile.preferred_name, inline=True) - embed.add_field(name="Major", value=profile.major, inline=True) - embed.add_field(name="Grad Year", value=str(profile.graduation_year), inline=True) - embed.add_field(name="Email", value=profile.school_email, inline=True) - embed.add_field(name="Minor", value=profile.minor or "N/A", inline=True) - embed.add_field(name="Description", value=profile.description or "N/A", inline=False) - - # Only show last 4 of ID for privacy in the embed - now = datetime.now(ZoneInfo("UTC")).strftime("%Y-%m-%d %H:%M") - embed.set_footer(text=f"Student ID: *****{profile.student_id[-4:]} • Last updated: {now}") - return embed + async def _send_error(self, interaction: discord.Interaction, title: str, message: str) -> None: + """Send a standard ephemeral error embed.""" + await interaction.response.send_message(embed=error_embed(title, message), ephemeral=True) async def setup(bot: commands.Bot) -> None: diff --git a/tests/capy_discord/exts/test_profile.py b/tests/capy_discord/exts/test_profile.py index 083e25d..16b0ec1 100644 --- a/tests/capy_discord/exts/test_profile.py +++ b/tests/capy_discord/exts/test_profile.py @@ -4,6 +4,7 @@ import pytest from discord.ext import commands +from capy_discord.exts.profile._schemas import UserProfileDetailsSchema, UserProfileSchema from capy_discord.exts.profile.profile import Profile @@ -19,17 +20,120 @@ def cog(bot): return Profile(bot) -@pytest.mark.asyncio -async def test_profile_create_opens_modal_immediately(cog): - interaction = MagicMock(spec=discord.Interaction) - interaction.user = MagicMock() - interaction.user.id = 123 - interaction.response = MagicMock() - interaction.response.send_message = AsyncMock() - interaction.response.send_modal = AsyncMock() - interaction.original_response = AsyncMock(return_value=MagicMock()) +@pytest.fixture +def interaction(): + mock_interaction = MagicMock(spec=discord.Interaction) + mock_interaction.user = MagicMock() + mock_interaction.user.id = 123 + mock_interaction.user.display_name = "Capy" + mock_interaction.user.display_avatar.url = "https://example.com/avatar.png" + mock_interaction.response = MagicMock() + mock_interaction.response.send_message = AsyncMock() + mock_interaction.response.send_modal = AsyncMock() + mock_interaction.followup = MagicMock() + mock_interaction.followup.send = AsyncMock() + mock_interaction.original_response = AsyncMock(return_value=MagicMock()) + return mock_interaction + +@pytest.mark.asyncio +async def test_profile_create_opens_modal_immediately(cog, interaction): await cog.profile.callback(cog, interaction, "create") interaction.response.send_message.assert_not_called() interaction.response.send_modal.assert_called_once() + + +@pytest.mark.asyncio +async def test_profile_create_with_existing_profile_shows_error(cog, interaction): + cog.service._profiles[interaction.user.id] = UserProfileSchema( + preferred_name="Existing User", + student_id="123456789", + school_email="existing@school.edu", + graduation_year=2028, + major="CS", + minor="ITWS", + description="Already here", + ) + + await cog.profile.callback(cog, interaction, "create") + + interaction.response.send_modal.assert_not_called() + interaction.response.send_message.assert_called_once() + embed = interaction.response.send_message.await_args.kwargs["embed"] + assert embed.title == "Profile Exists" + + +@pytest.mark.asyncio +async def test_profile_update_without_existing_profile_shows_error(cog, interaction): + await cog.profile.callback(cog, interaction, "update") + + interaction.response.send_modal.assert_not_called() + interaction.response.send_message.assert_called_once() + embed = interaction.response.send_message.await_args.kwargs["embed"] + assert embed.title == "No Profile" + + +@pytest.mark.asyncio +async def test_profile_show_returns_embed_for_existing_profile(cog, interaction): + cog.service._profiles[interaction.user.id] = UserProfileSchema( + preferred_name="Capy Bara", + student_id="123456789", + school_email="capy@school.edu", + graduation_year=2027, + major="Computer Science", + minor="ITWS", + description="Likes clean code", + ) + + await cog.profile.callback(cog, interaction, "show") + + interaction.response.send_message.assert_called_once() + embed = interaction.response.send_message.await_args.kwargs["embed"] + assert embed.title == "Capy's Profile" + + +@pytest.mark.asyncio +async def test_profile_show_without_profile_shows_error(cog, interaction): + await cog.profile.callback(cog, interaction, "show") + + interaction.response.send_message.assert_called_once() + embed = interaction.response.send_message.await_args.kwargs["embed"] + assert embed.title == "No Profile" + + +@pytest.mark.asyncio +async def test_handle_profile_details_submit_creates_profile(cog, interaction): + details = UserProfileDetailsSchema(minor="ITWS", description="Builds bots") + profile_data = { + "preferred_name": "Capy Bara", + "student_id": "123456789", + "school_email": "capy@school.edu", + "graduation_year": 2027, + "major": "Computer Science", + } + + await cog._handle_profile_details_submit(interaction, details, profile_data, "create") + + assert interaction.user.id in cog.service._profiles + interaction.response.send_message.assert_called_once() + embeds = interaction.response.send_message.await_args.kwargs["embeds"] + assert embeds[0].title == "Profile Created" + + +@pytest.mark.asyncio +async def test_handle_profile_details_submit_with_invalid_data_shows_error(cog, interaction): + details = UserProfileDetailsSchema(minor="ITWS", description="Builds bots") + invalid_profile_data = { + "preferred_name": "Capy Bara", + "student_id": "invalid", + "school_email": "capy@school.edu", + "graduation_year": 2027, + "major": "Computer Science", + } + + await cog._handle_profile_details_submit(interaction, details, invalid_profile_data, "create") + + interaction.response.send_message.assert_called_once() + embed = interaction.response.send_message.await_args.kwargs["embed"] + assert embed.title == "Profile Validation Failed"