diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 40a8c73..18694e2 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -59,6 +59,12 @@ "cOnboarder", "cOnboardingCoordinator", ) +_DISCORD_ROLES_PROTECTED_FROM_APPLY: frozenset[str] = frozenset( + {"Member", "Admin", "Steering Committee"} +) +_DISCORD_ROLES_PROTECTED_FROM_APPLY_CASEFOLDED: frozenset[str] = frozenset( + name.casefold() for name in _DISCORD_ROLES_PROTECTED_FROM_APPLY +) EXCLUDED_ONBOARDING_STATES = frozenset({"onboarded", "waitlist", "rejected"}) ONBOARDING_QUEUE_MAX_SIZE = 200 ONBOARDING_QUEUE_PAGE_SIZE = 1 @@ -979,6 +985,68 @@ async def on_submit(self, interaction: discord.Interaction) -> None: ) +class ResumeEditDiscordRolesModal(discord.ui.Modal, title="Edit Discord Roles"): + """Modal for editing suggested Discord roles before applying.""" + + discord_roles_input: discord.ui.TextInput = discord.ui.TextInput( + label="Discord Roles (one per line)", + style=discord.TextStyle.paragraph, + required=False, + max_length=1000, + ) + + def __init__(self, *, confirmation_view: "ResumeUpdateConfirmationView") -> None: + super().__init__() + self.confirmation_view = confirmation_view + self.discord_roles_input.default = "\n".join( + confirmation_view.discord_role_suggestions + ) + + async def on_submit(self, interaction: discord.Interaction) -> None: + raw = self.discord_roles_input.value or "" + normalized: list[str] = [] + blocked: list[str] = [] + seen: set[str] = set() + for role in raw.splitlines(): + role_name = role.strip() + if not role_name: + continue + key = role_name.casefold() + if key in _DISCORD_ROLES_PROTECTED_FROM_APPLY_CASEFOLDED: + blocked.append(role_name) + continue + if key in seen: + continue + seen.add(key) + normalized.append(role_name) + + self.confirmation_view.discord_role_suggestions = normalized + for item in self.confirmation_view.children: + if isinstance(item, ResumeApplyDiscordRolesButton): + item.disabled = not bool(normalized) + + if normalized: + count = len(normalized) + await interaction.response.send_message( + f"✅ Discord roles updated to {count} role{'s' if count != 1 else ''}.", + ephemeral=True, + ) + return + + if blocked: + await interaction.response.send_message( + "✅ No assignable Discord roles specified. " + "Protected roles (Member, Admin, Steering Committee) cannot be added.", + ephemeral=True, + ) + return + + await interaction.response.send_message( + "✅ Discord role suggestions cleared. Add roles above to enable apply.", + ephemeral=True, + ) + + class ResumeEditLocationModal(discord.ui.Modal, title="Edit Location"): """Modal for editing proposed location fields before confirmation.""" @@ -1176,6 +1244,302 @@ async def callback(self, interaction: discord.Interaction) -> None: ) +class ResumeEditDiscordRolesButton(discord.ui.Button["ResumeUpdateConfirmationView"]): + """Button that opens the Discord role editor modal.""" + + def __init__(self) -> None: + super().__init__( + label="Edit Discord Roles", + style=discord.ButtonStyle.secondary, + custom_id="resume_edit_discord_roles", + ) + + async def callback(self, interaction: discord.Interaction) -> None: + view = self.view + if not isinstance(view, ResumeUpdateConfirmationView): + await interaction.response.send_message( + "❌ Unable to edit Discord roles.", ephemeral=True + ) + return + await interaction.response.send_modal( + ResumeEditDiscordRolesModal(confirmation_view=view) + ) + + +class ResumeApplyDiscordRolesButton(discord.ui.Button["ResumeUpdateConfirmationView"]): + """Button that applies suggested Discord roles to the linked member.""" + + def __init__(self) -> None: + super().__init__( + label="Apply Discord Roles", + style=discord.ButtonStyle.success, + custom_id="resume_apply_discord_roles", + ) + + async def callback(self, interaction: discord.Interaction) -> None: + view = self.view + if not isinstance(view, ResumeUpdateConfirmationView): + await interaction.response.send_message( + "❌ Unable to apply Discord roles.", ephemeral=True + ) + return + + target_user_id_raw: str | None = None + + def _audit_apply_roles_event( + result: str, + stage: str, + metadata: dict[str, Any] | None = None, + ) -> None: + view.crm_cog._audit_command_safe( + interaction=interaction, + action="apply_discord_roles", + result=result, + metadata={ + "contact_id": view.contact_id, + "action_by_user_id": str(interaction.user.id), + "target_user_id": target_user_id_raw, + "stage": stage, + **(metadata or {}), + }, + resource_type="crm_contact", + resource_id=view.contact_id, + ) + + if not interaction.guild: + await interaction.response.send_message( + "❌ Discord roles can only be managed inside a server.", + ephemeral=True, + ) + _audit_apply_roles_event( + "denied", + "missing_guild", + {"reason": "callback must be used in guild context"}, + ) + return + + target_user_id = view.discord_role_target_user_id + if not target_user_id and isinstance(view.link_discord, dict): + target_user_id = view.link_discord.get("user_id") + target_user_id_raw = target_user_id + if not target_user_id: + await interaction.response.send_message( + "❌ No linked Discord user found for role assignment.", + ephemeral=True, + ) + _audit_apply_roles_event( + "denied", + "missing_linked_user", + {"reason": "no_linked_discord_user"}, + ) + return + + try: + target_user_id_int = int(target_user_id) + except (TypeError, ValueError): + await interaction.response.send_message( + "❌ Invalid linked Discord user ID.", ephemeral=True + ) + _audit_apply_roles_event( + "error", + "invalid_target_user_id", + { + "reason": "invalid_linked_discord_user_id", + "value": str(target_user_id), + }, + ) + return + + target_member = interaction.guild.get_member(target_user_id_int) + if not target_member: + try: + target_member = await interaction.guild.fetch_member(target_user_id_int) + except Exception: + target_member = None + if not target_member: + await interaction.response.send_message( + "❌ Linked Discord user is not in this server.", + ephemeral=True, + ) + _audit_apply_roles_event( + "denied", + "target_not_in_server", + {"reason": "member_not_in_guild"}, + ) + return + + bot_member = interaction.guild.me + if not bot_member: + await interaction.response.send_message( + "❌ Unable to resolve bot member for role validation.", + ephemeral=True, + ) + _audit_apply_roles_event( + "error", + "missing_bot_member", + {"reason": "bot_member_unresolved"}, + ) + return + + if not bot_member.guild_permissions.manage_roles: + await interaction.response.send_message( + "❌ Bot missing **Manage Roles** permission.", + ephemeral=True, + ) + _audit_apply_roles_event( + "error", + "missing_manage_roles_permission", + {"reason": "manage_roles_permission_missing"}, + ) + return + + if not (bot_member.top_role > target_member.top_role): + await interaction.response.send_message( + "❌ Bot role position is not high enough to edit this member.", + ephemeral=True, + ) + _audit_apply_roles_event( + "denied", + "bot_role_too_low", + {"reason": "hierarchy_check_failed"}, + ) + return + + suggested_roles = view.discord_role_suggestions + if not suggested_roles: + await interaction.response.send_message( + "ℹ️ No Discord role suggestions to apply.", + ephemeral=True, + ) + _audit_apply_roles_event( + "denied", + "no_suggestions", + {"reason": "no_discord_role_suggestions"}, + ) + return + + guild_roles: list[discord.Role] = list(interaction.guild.roles) + name_to_role: dict[str, discord.Role] = { + role.name: role for role in guild_roles + } + existing = {role.name for role in target_member.roles} + + role_add: list[discord.Role] = [] + already_assigned: list[str] = [] + missing: list[str] = [] + blocked: list[str] = [] + protected: list[str] = [] + for role_name in suggested_roles: + if role_name.casefold() in _DISCORD_ROLES_PROTECTED_FROM_APPLY_CASEFOLDED: + protected.append(role_name) + continue + if role_name in existing: + already_assigned.append(role_name) + continue + role = name_to_role.get(role_name) + if not role: + missing.append(role_name) + continue + if not (bot_member.top_role > role): + blocked.append(role_name) + continue + role_add.append(role) + + if ( + not role_add + and not already_assigned + and not protected + and not missing + and not blocked + ): + await interaction.response.send_message( + "⚠️ None of the suggested roles are assignable right now.", + ephemeral=True, + ) + _audit_apply_roles_event( + "denied", + "no_assignable_roles", + {"reason": "all_suggestions_filtered", "suggestions": suggested_roles}, + ) + return + + await interaction.response.defer(thinking=True, ephemeral=True) + try: + if role_add: + await target_member.add_roles( + *role_add, + reason=( + f"Resume role suggestions applied via bot for " + f"contact {view.contact_id}" + ), + ) + except discord.HTTPException as exc: + logger.error( + "Failed to apply Discord roles for contact_id=%s user=%s: %s", + view.contact_id, + target_user_id_int, + exc, + ) + await interaction.followup.send( + "⚠️ Failed to apply Discord roles. Verify bot permissions and try again.", + ephemeral=True, + ) + _audit_apply_roles_event( + "error", + "role_apply_failed", + { + "reason": "discord_http_exception", + "error": str(exc), + "roles_requested": [role.name for role in role_add], + }, + ) + return + + if role_add: + for item in view.children: + if isinstance(item, ResumeApplyDiscordRolesButton): + item.disabled = True + + if interaction.message: + try: + await interaction.message.edit(view=view) + except discord.HTTPException as exc: + logger.warning("Failed to update role apply button: %s", exc) + + summary_lines: list[str] = [] + if role_add: + summary_lines.append( + f"✅ Applied: {', '.join(role.name for role in role_add)}" + ) + else: + summary_lines.append("✅ No new roles to apply.") + if already_assigned: + summary_lines.append(f"Already present: {', '.join(already_assigned)}") + if missing: + summary_lines.append(f"Not found: {', '.join(missing)}") + if blocked: + summary_lines.append(f"Blocked by hierarchy: {', '.join(blocked)}") + if protected: + summary_lines.append(f"Protected roles blocked: {', '.join(protected)}") + + await interaction.followup.send( + "\n".join(summary_lines), + ephemeral=True, + ) + _audit_apply_roles_event( + "success", + "apply_complete", + { + "applied": [role.name for role in role_add], + "already_assigned": already_assigned, + "missing": missing, + "blocked": blocked, + "protected": protected, + "summary": "\n".join(summary_lines), + }, + ) + + class ResumeEditLocationButton(discord.ui.Button["ResumeUpdateConfirmationView"]): """Button that opens the Edit Location modal.""" @@ -1210,6 +1574,7 @@ class ResumeUpdateConfirmationView(discord.ui.View): "emailAddressData": "Email Address", "cRoles": "Roles", "cGitHubUsername": "GitHub", + "description": "Description", "phoneNumber": "Phone", "skills": "Skills", "cSkillAttrs": "Skill Strengths", @@ -1238,6 +1603,8 @@ def __init__( proposed_updates: dict[str, Any], link_discord: dict[str, str] | None = None, parsed_seniority: str | None = None, + discord_role_suggestions: list[str] | None = None, + discord_role_target_user_id: str | None = None, ) -> None: super().__init__(timeout=300) self.crm_cog = crm_cog @@ -1247,6 +1614,10 @@ def __init__( self.proposed_updates = proposed_updates self.link_discord = link_discord self.parsed_seniority = parsed_seniority + self.discord_role_target_user_id = discord_role_target_user_id + self.discord_role_suggestions = list( + dict.fromkeys(discord_role_suggestions or []) + ) self.seniority_override: str | None = None if parsed_seniority: @@ -1264,6 +1635,9 @@ def __init__( self.add_item(ResumeEditSkillsButton()) if proposed_updates.get("cRoles"): self.add_item(ResumeEditRolesButton()) + if self.discord_role_suggestions: + self.add_item(ResumeEditDiscordRolesButton()) + self.add_item(ResumeApplyDiscordRolesButton()) self.add_item(ResumeEditLocationButton()) def _set_seniority_override(self, value: str) -> str: @@ -3116,39 +3490,20 @@ def format_skill_delta(current: Any, proposed: Any) -> str: ), inline=False, ) - raw_llm_output = extracted_profile.get("raw_llm_output") - raw_llm_json = extracted_profile.get("raw_llm_json") llm_fallback_reason = extracted_profile.get("llm_fallback_reason") - if raw_llm_output or raw_llm_json or llm_fallback_reason: - debug_value = ( - "Attached raw extraction payload: `resume-extract-debug.json`" - ) - if llm_fallback_reason: - debug_value += "\n" + truncate_field_value( + if llm_fallback_reason: + debug_value = " ".join( + [ + "File: `resume-extract-debug.json`", f"Fallback: {llm_fallback_reason}", - limit=180, - ) + ] + ) embed.add_field( name="Debug", value=truncate_field_value(debug_value), inline=False, ) evidence_lines: list[str] = [] - current_title = str(extracted_profile.get("current_title") or "").strip() - if current_title: - evidence_lines.append( - f"Title: `{truncate_preview_value(current_title, label='title')}`" - ) - recent_titles = extracted_profile.get("recent_titles") - if isinstance(recent_titles, list) and recent_titles: - formatted_recent_titles = ", ".join( - str(item).strip() for item in recent_titles[:3] if str(item).strip() - ) - if formatted_recent_titles: - evidence_lines.append( - "Recent titles: " - f"`{truncate_preview_value(formatted_recent_titles, label='title')}`" - ) current_location_raw = str( extracted_profile.get("current_location_raw") or "" ).strip() @@ -3250,11 +3605,67 @@ def _build_resume_extract_debug_file( filename="resume-extract-debug.json", ) + @staticmethod + def _build_discord_role_suggestions( + extracted_profile: dict[str, Any], + current_discord_roles: list[str] | None = None, + ) -> tuple[list[str], list[str]]: + skills = extracted_profile.get("skills") or [] + primary_roles = extracted_profile.get("primary_roles") or [] + country = extracted_profile.get("address_country") or extracted_profile.get( + "addressCountry" + ) + + if not isinstance(skills, (list, tuple, set)): + skills = [] + if not isinstance(primary_roles, (list, tuple, set)): + primary_roles = [] + + if isinstance(country, (list, tuple, set)): + country = next( + ( + entry + for entry in country + if isinstance(entry, str) and entry.strip() + ), + None, + ) + if not isinstance(country, str): + country = None + + technical = suggest_technical_discord_roles( + [str(role).strip() for role in skills], + [str(role).strip() for role in primary_roles], + ) + locality = suggest_locality_discord_roles(country) + + technical = [r for r in technical if r not in DISCORD_ROLES_NEVER_SUGGEST] + locality = [r for r in locality if r not in DISCORD_ROLES_NEVER_SUGGEST] + technical = [ + r + for r in technical + if r.casefold() not in _DISCORD_ROLES_PROTECTED_FROM_APPLY_CASEFOLDED + ] + locality = [ + r + for r in locality + if r.casefold() not in _DISCORD_ROLES_PROTECTED_FROM_APPLY_CASEFOLDED + ] + + if current_discord_roles is not None: + existing = {role.casefold() for role in current_discord_roles} + technical = [r for r in technical if r.casefold() not in existing] + locality = [r for r in locality if r.casefold() not in existing] + + return technical, locality + def _build_role_suggestions_embed( self, *, contact_name: str, - extracted_profile: dict[str, Any], + technical_roles: list[str] | None = None, + locality_roles: list[str] | None = None, + extracted_profile: dict[str, Any] | None = None, current_discord_roles: list[str] | None = None, ) -> discord.Embed | None: """Build a separate embed suggesting Discord roles to add based on resume data. @@ -3262,22 +3673,13 @@ def _build_role_suggestions_embed( Only ever suggests additions — roles are never removed. Never suggests roles in DISCORD_ROLES_NEVER_SUGGEST. """ - skills: list[str] = extracted_profile.get("skills") or [] - primary_roles: list[str] = extracted_profile.get("primary_roles") or [] - country: str | None = extracted_profile.get("address_country") - - technical = suggest_technical_discord_roles(skills, primary_roles) - locality = suggest_locality_discord_roles(country) - - # Filter roles that should never be suggested - technical = [r for r in technical if r not in DISCORD_ROLES_NEVER_SUGGEST] - locality = [r for r in locality if r not in DISCORD_ROLES_NEVER_SUGGEST] - - # If we know the member's current roles, only show missing ones - if current_discord_roles is not None: - existing = set(current_discord_roles) - technical = [r for r in technical if r not in existing] - locality = [r for r in locality if r not in existing] + if technical_roles is None and locality_roles is None: + technical_roles, locality_roles = self._build_discord_role_suggestions( + extracted_profile or {}, + current_discord_roles=current_discord_roles, + ) + technical = technical_roles or [] + locality = locality_roles or [] if not technical and not locality: return None @@ -3486,6 +3888,8 @@ async def _run_resume_extract_and_preview( # Build role suggestions embed for reprocess actions or uploads with a linked user. role_suggestions_embed: discord.Embed | None = None + suggested_discord_roles: list[str] = [] + discord_role_target_user_id: str | None = None if action_name == "crm.reprocess_resume" or ( action_name == "crm.upload_resume" and link_member ): @@ -3498,6 +3902,8 @@ async def _run_resume_extract_and_preview( settings, contact_id, ) + if discord_user_id: + discord_role_target_user_id = str(discord_user_id) if discord_user_id and interaction.guild: guild_member = interaction.guild.get_member( int(discord_user_id) @@ -3510,19 +3916,61 @@ async def _run_resume_extract_and_preview( exc, ) elif link_member: + discord_role_target_user_id = str(link_member.id) try: current_discord_roles = [r.name for r in link_member.roles] except Exception as exc: logger.warning( "Could not read linked member roles for suggestions: %s", exc ) + technical_suggestions, locality_suggestions = ( + self._build_discord_role_suggestions( + extracted_profile=extracted_profile, + current_discord_roles=current_discord_roles, + ) + ) + suggested_discord_roles = list( + dict.fromkeys(technical_suggestions + locality_suggestions) + ) role_suggestions_embed = self._build_role_suggestions_embed( contact_name=contact_name, - extracted_profile=extracted_profile, - current_discord_roles=current_discord_roles, + technical_roles=technical_suggestions, + locality_roles=locality_suggestions, ) if not proposed_updates and not link_member and not parsed_seniority: + if role_suggestions_embed is None: + if action_name != "crm.reprocess_resume": + self._audit_command( + interaction=interaction, + action=action_name, + result="success", + metadata={ + "filename": filename, + "attachment_id": attachment_id, + "job_id": job_id, + "stage": "preview_no_changes", + }, + resource_type="crm_contact", + resource_id=str(contact_id), + ) + await interaction.followup.send( + embeds=[embed], + file=debug_file, + ephemeral=True, + ) + return + + view = ResumeUpdateConfirmationView( + crm_cog=self, + requester_id=interaction.user.id, + contact_id=contact_id, + contact_name=contact_name, + proposed_updates=proposed_updates, + parsed_seniority=parsed_seniority, + discord_role_suggestions=suggested_discord_roles, + discord_role_target_user_id=discord_role_target_user_id, + ) if action_name != "crm.reprocess_resume": self._audit_command( interaction=interaction, @@ -3532,7 +3980,9 @@ async def _run_resume_extract_and_preview( "filename": filename, "attachment_id": attachment_id, "job_id": job_id, - "stage": "preview_no_changes", + "stage": "preview_ready", + "proposed_updates_count": len(proposed_updates), + "role_suggestions_count": len(suggested_discord_roles), }, resource_type="crm_contact", resource_id=str(contact_id), @@ -3543,6 +3993,7 @@ async def _run_resume_extract_and_preview( await interaction.followup.send( embeds=embeds, file=debug_file, + view=view, ephemeral=True, ) return @@ -3562,6 +4013,8 @@ async def _run_resume_extract_and_preview( proposed_updates=proposed_updates, link_discord=link_discord_payload, parsed_seniority=parsed_seniority, + discord_role_suggestions=suggested_discord_roles, + discord_role_target_user_id=discord_role_target_user_id, ) if action_name != "crm.reprocess_resume": self._audit_command( diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index d948f34..7a2bc7d 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -27,6 +27,9 @@ ResumeEditSkillsModal, ResumeEditRolesButton, ResumeEditRolesModal, + ResumeApplyDiscordRolesButton, + ResumeEditDiscordRolesButton, + ResumeEditDiscordRolesModal, _extract_parsed_seniority, _format_seniority_label, ) @@ -370,6 +373,38 @@ class DummyProfile: assert _extract_parsed_seniority(DummyProfile()) == "midlevel" + def test_build_discord_role_suggestions_filters_current_roles_and_blocked_terms( + self, crm_cog + ): + """Discord role suggestions should filter existing roles and blocked terms.""" + extracted_profile = { + "skills": ["Python", "Python"], + "primary_roles": ["Staff Engineer"], + "address_country": "Taiwan", + } + + with ( + patch( + "five08.discord_bot.cogs.crm.suggest_technical_discord_roles", + return_value=["Backend", "Manager", "Member", "Steering Committee"], + ), + patch( + "five08.discord_bot.cogs.crm.suggest_locality_discord_roles", + return_value=["Taiwan", "Manager"], + ), + patch( + "five08.discord_bot.cogs.crm.DISCORD_ROLES_NEVER_SUGGEST", + {"Manager"}, + ), + ): + technical, locality = crm_cog._build_discord_role_suggestions( + extracted_profile=extracted_profile, + current_discord_roles=["backend"], + ) + + assert technical == [] + assert locality == ["Taiwan"] + @pytest.mark.asyncio async def test_resume_update_view_adds_seniority_select(self, crm_cog): """Resume update view should expose a seniority override dropdown.""" @@ -569,6 +604,47 @@ async def test_resume_update_view_no_roles_button_without_roles(self, crm_cog): isinstance(child, ResumeEditRolesButton) for child in view.children ) + @pytest.mark.asyncio + async def test_resume_update_view_adds_discord_roles_buttons_with_suggestions( + self, crm_cog + ): + """Discord roles edit/apply buttons should appear when suggestions exist.""" + view = ResumeUpdateConfirmationView( + crm_cog=crm_cog, + requester_id=123, + contact_id="contact-1", + contact_name="Test User", + proposed_updates={}, + discord_role_suggestions=["Backend", "Taiwan"], + ) + + assert any( + isinstance(child, ResumeEditDiscordRolesButton) for child in view.children + ) + assert any( + isinstance(child, ResumeApplyDiscordRolesButton) for child in view.children + ) + + @pytest.mark.asyncio + async def test_resume_update_view_without_discord_role_suggestions_no_role_buttons( + self, crm_cog + ): + """Discord role buttons should not appear when no suggestions exist.""" + view = ResumeUpdateConfirmationView( + crm_cog=crm_cog, + requester_id=123, + contact_id="contact-1", + contact_name="Test User", + proposed_updates={}, + ) + + assert not any( + isinstance(child, ResumeEditDiscordRolesButton) for child in view.children + ) + assert not any( + isinstance(child, ResumeApplyDiscordRolesButton) for child in view.children + ) + @pytest.mark.asyncio async def test_edit_websites_modal_prepopulates_list_values(self, crm_cog): """Edit Websites modal should pre-fill with proposed website list, one per line.""" @@ -665,6 +741,145 @@ async def test_edit_roles_modal_prepopulates_list_values(self, crm_cog): assert modal.roles_input.default == "developer\nmarketing" + @pytest.mark.asyncio + async def test_edit_discord_roles_modal_prepopulates_values(self, crm_cog): + """Discord Roles modal should pre-fill with suggested role list.""" + view = ResumeUpdateConfirmationView( + crm_cog=crm_cog, + requester_id=123, + contact_id="contact-1", + contact_name="Test User", + proposed_updates={}, + discord_role_suggestions=["Backend", "Operations"], + ) + + modal = ResumeEditDiscordRolesModal(confirmation_view=view) + + assert modal.discord_roles_input.default == "Backend\nOperations" + + @pytest.mark.asyncio + async def test_edit_discord_roles_modal_submit_updates_suggested_roles( + self, crm_cog + ): + """Submitting the Edit Discord Roles modal should deduplicate and update suggestions.""" + view = ResumeUpdateConfirmationView( + crm_cog=crm_cog, + requester_id=123, + contact_id="contact-1", + contact_name="Test User", + proposed_updates={}, + discord_role_suggestions=["Backend"], + ) + modal = ResumeEditDiscordRolesModal(confirmation_view=view) + modal.discord_roles_input._value = ( + "Backend\nbackend\nOperations\nSteering Committee\nAdmin\n" + ) + interaction = AsyncMock() + interaction.response = AsyncMock() + interaction.response.send_message = AsyncMock() + + await modal.on_submit(interaction) + + assert view.discord_role_suggestions == ["Backend", "Operations"] + apply_button = next( + child + for child in view.children + if isinstance(child, ResumeApplyDiscordRolesButton) + ) + assert apply_button.disabled is False + interaction.response.send_message.assert_called_once_with( + "✅ Discord roles updated to 2 roles.", + ephemeral=True, + ) + + @pytest.mark.asyncio + async def test_apply_discord_roles_button_applies_roles_and_reports_results( + self, crm_cog + ): + """Apply Discord Roles should grant assignable roles and report status.""" + + class _Role: + def __init__(self, name: str, position: int, role_id: int = 0) -> None: + self.name = name + self.position = position + self.id = role_id + + def __gt__(self, other: object) -> bool: + if not isinstance(other, _Role): + return False + if self.position != other.position: + return self.position > other.position + return self.id > other.id + + bot_top_role = _Role("Bot", 99) + target_top_role = _Role("Member", 10) + backend_role = _Role("Backend", 30) + blocked_role = _Role("Blocked", 150) + manager_role = _Role("Manager", 5) + + bot_member = Mock() + bot_member.top_role = bot_top_role + bot_member.guild_permissions = Mock() + bot_member.guild_permissions.manage_roles = True + + target_member = Mock() + target_member.top_role = target_top_role + target_member.roles = [manager_role] + target_member.add_roles = AsyncMock() + + interaction = AsyncMock() + interaction.guild = Mock() + interaction.guild.get_member = Mock(return_value=target_member) + interaction.guild.fetch_member = AsyncMock() + interaction.guild.me = bot_member + interaction.guild.roles = [backend_role, blocked_role, manager_role] + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.response.send_message = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.message = Mock() + interaction.message.edit = AsyncMock() + + view = ResumeUpdateConfirmationView( + crm_cog=crm_cog, + requester_id=123, + contact_id="contact-1", + contact_name="Test User", + proposed_updates={}, + discord_role_suggestions=[ + "Backend", + "Manager", + "Missing", + "Blocked", + "Member", + "Admin", + "Steering Committee", + ], + discord_role_target_user_id="1001", + ) + button = next( + child + for child in view.children + if isinstance(child, ResumeApplyDiscordRolesButton) + ) + + await button.callback(interaction) + + target_member.add_roles.assert_awaited_once() + called_args = target_member.add_roles.await_args.args + assert called_args[0].name == "Backend" + interaction.response.defer.assert_awaited_once_with( + thinking=True, ephemeral=True + ) + interaction.message.edit.assert_awaited_once_with(view=view) + summary = interaction.followup.send.call_args.args[0] + assert "✅ Applied: Backend" in summary + assert "Already present: Manager" in summary + assert "Not found: Missing" in summary + assert "Blocked by hierarchy: Blocked" in summary + assert "Protected roles blocked: Member, Admin, Steering Committee" in summary + @pytest.mark.asyncio async def test_edit_websites_modal_submit_updates_proposed( self, crm_cog, mock_interaction