Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions capy_discord/exts/profile/_service.py
Original file line number Diff line number Diff line change
@@ -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
162 changes: 81 additions & 81 deletions capy_discord/exts/profile/profile.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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:
Expand All @@ -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,
Expand All @@ -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)

Expand All @@ -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")
Expand All @@ -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)

Expand All @@ -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),
Expand All @@ -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,
)
Expand All @@ -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()
Expand All @@ -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:
Expand Down
Loading
Loading