diff --git a/alembic/versions/c3e7b1f9d2a4_add_xp_rank_role_category.py b/alembic/versions/c3e7b1f9d2a4_add_xp_rank_role_category.py new file mode 100644 index 0000000..0f1f316 --- /dev/null +++ b/alembic/versions/c3e7b1f9d2a4_add_xp_rank_role_category.py @@ -0,0 +1,38 @@ +"""Add XP_RANK to dynamic_role category enum + +Revision ID: c3e7b1f9d2a4 +Revises: 9aa21aede2ec +Create Date: 2026-06-02 00:00:00.000000 + +""" +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'c3e7b1f9d2a4' +down_revision = '9aa21aede2ec' +branch_labels = None +depends_on = None + +_BASE_VALUES = ( + 'RANK', 'SEASON', 'SUBSCRIPTION_LABS', 'SUBSCRIPTION_ACADEMY', + 'CREATOR', 'POSITION', 'ACADEMY_CERT', 'JOINABLE', +) + + +def upgrade() -> None: + op.alter_column( + 'dynamic_role', 'category', + existing_type=mysql.ENUM(*_BASE_VALUES, name='rolecategory'), + type_=mysql.ENUM(*_BASE_VALUES, 'XP_RANK', name='rolecategory'), + existing_nullable=False, + ) + + +def downgrade() -> None: + op.alter_column( + 'dynamic_role', 'category', + existing_type=mysql.ENUM(*_BASE_VALUES, 'XP_RANK', name='rolecategory'), + type_=mysql.ENUM(*_BASE_VALUES, name='rolecategory'), + existing_nullable=False, + ) diff --git a/alembic/versions/d4f8c2a6e1b7_add_xp_grade_role_category.py b/alembic/versions/d4f8c2a6e1b7_add_xp_grade_role_category.py new file mode 100644 index 0000000..edbd413 --- /dev/null +++ b/alembic/versions/d4f8c2a6e1b7_add_xp_grade_role_category.py @@ -0,0 +1,38 @@ +"""Add XP_GRADE to dynamic_role category enum + +Revision ID: d4f8c2a6e1b7 +Revises: c3e7b1f9d2a4 +Create Date: 2026-06-02 00:00:01.000000 + +""" +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = 'd4f8c2a6e1b7' +down_revision = 'c3e7b1f9d2a4' +branch_labels = None +depends_on = None + +_BASE_VALUES = ( + 'RANK', 'SEASON', 'SUBSCRIPTION_LABS', 'SUBSCRIPTION_ACADEMY', + 'CREATOR', 'POSITION', 'ACADEMY_CERT', 'JOINABLE', 'XP_RANK', +) + + +def upgrade() -> None: + op.alter_column( + 'dynamic_role', 'category', + existing_type=mysql.ENUM(*_BASE_VALUES, name='rolecategory'), + type_=mysql.ENUM(*_BASE_VALUES, 'XP_GRADE', name='rolecategory'), + existing_nullable=False, + ) + + +def downgrade() -> None: + op.alter_column( + 'dynamic_role', 'category', + existing_type=mysql.ENUM(*_BASE_VALUES, 'XP_GRADE', name='rolecategory'), + type_=mysql.ENUM(*_BASE_VALUES, name='rolecategory'), + existing_nullable=False, + ) diff --git a/scripts/seed_dynamic_roles.py b/scripts/seed_dynamic_roles.py index 712ce2e..ecc24b3 100644 --- a/scripts/seed_dynamic_roles.py +++ b/scripts/seed_dynamic_roles.py @@ -54,6 +54,18 @@ ("SEASON_RUBY", RoleCategory.SEASON, "Ruby", "Ruby", {}), ("SEASON_SILVER", RoleCategory.SEASON, "Silver", "Silver", {}), ("SEASON_BRONZE", RoleCategory.SEASON, "Bronze", "Bronze", {}), + # XP-system ranks (one role per tier, grades I/II/III collapsed) + ("XP_BEGINNER", RoleCategory.XP_RANK, "Beginner", "Beginner", {}), + ("XP_APPRENTICE", RoleCategory.XP_RANK, "Apprentice", "Apprentice", {}), + ("XP_SKILLED", RoleCategory.XP_RANK, "Skilled", "Skilled", {}), + ("XP_PROFESSIONAL", RoleCategory.XP_RANK, "Professional", "Professional", {}), + ("XP_MASTER", RoleCategory.XP_RANK, "Master", "Master", {}), + ("XP_PRODIGY", RoleCategory.XP_RANK, "Prodigy", "Prodigy", {}), + ("XP_GRANDMASTER", RoleCategory.XP_RANK, "Grandmaster", "Grandmaster", {}), + # XP-system grades (mutually exclusive with each other, independent of tiers) + ("XP_GRADE_I", RoleCategory.XP_GRADE, "I", "Grade I", {}), + ("XP_GRADE_II", RoleCategory.XP_GRADE, "II", "Grade II", {}), + ("XP_GRADE_III", RoleCategory.XP_GRADE, "III", "Grade III", {}), # Academy Certs (with cert_full_name and cert_integer_id) ("ACADEMY_CWES", RoleCategory.ACADEMY_CERT, "CWES", "Certified Web Exploitation Specialist", { "cert_full_name": "HTB Certified Web Exploitation Specialist", diff --git a/src/database/models/dynamic_role.py b/src/database/models/dynamic_role.py index bf1341d..eaa0bb8 100644 --- a/src/database/models/dynamic_role.py +++ b/src/database/models/dynamic_role.py @@ -18,6 +18,8 @@ class RoleCategory(str, enum.Enum): POSITION = "position" ACADEMY_CERT = "academy_cert" JOINABLE = "joinable" + XP_RANK = "xp_rank" + XP_GRADE = "xp_grade" class DynamicRole(Base): diff --git a/src/services/role_manager.py b/src/services/role_manager.py index 33aa5f1..07392dd 100644 --- a/src/services/role_manager.py +++ b/src/services/role_manager.py @@ -168,6 +168,14 @@ def get_season_role_id(self, tier: str) -> Optional[int]: """Get season tier role (Holo, Platinum, etc.).""" return self.get_role_id(RoleCategory.SEASON.value, tier) + def get_xp_rank_role_id(self, rank_name: str) -> Optional[int]: + """Get XP-system rank role by tier name (Beginner, Apprentice, ...).""" + return self.get_role_id(RoleCategory.XP_RANK.value, rank_name) + + def get_xp_grade_role_id(self, grade: str) -> Optional[int]: + """Get XP-system grade role by grade (I, II, III).""" + return self.get_role_id(RoleCategory.XP_GRADE.value, grade) + # ── Cross-category lookup ──────────────────────────────────────── def get_post_or_rank(self, what: str) -> Optional[int]: diff --git a/src/webhooks/handlers/account.py b/src/webhooks/handlers/account.py index 517f4a3..fd758c8 100644 --- a/src/webhooks/handlers/account.py +++ b/src/webhooks/handlers/account.py @@ -26,6 +26,8 @@ async def handle(self, body: WebhookBody, bot: Bot): return await self._handle_account_banned(body, bot) elif body.event == WebhookEvent.NAME_CHANGE: return await self._handle_name_change(body, bot) + elif body.event == WebhookEvent.XP_RANK_CHANGE: + return await self._handle_xp_rank_change(body, bot) else: raise ValueError(f"Invalid event: {body.event}") @@ -126,6 +128,45 @@ async def _handle_account_banned(self, body: WebhookBody, bot: Bot) -> dict: return self.success() + async def _handle_xp_rank_change(self, body: WebhookBody, bot: Bot) -> dict: + """ + Handles the XP-system rank change event. + + Assigns the XP tier role and the XP grade role. Tier roles are mutually + exclusive with each other and grade roles are mutually exclusive with + each other, but the two groups are independent: a member can hold one + tier role and one grade role at the same time. + """ + discord_id, account_id = self.validate_common_properties(body) + extra = {"account_id": account_id, "discord_id": discord_id} + + xp_rank = self.validate_property( + self.get_property_or_trait(body, "xp_rank"), "xp_rank" + ) + rank_role_id = bot.role_manager.get_xp_rank_role_id(xp_rank) + if not rank_role_id: + err = ValueError(f"Cannot find role for XP rank '{xp_rank}'") + self.logger.error(err, extra={**extra, "xp_rank": xp_rank}) + raise err + + xp_grade = self.validate_property( + self.get_property_or_trait(body, "xp_grade"), "xp_grade" + ) + grade_role_id = bot.role_manager.get_xp_grade_role_id(xp_grade) + if not grade_role_id: + err = ValueError(f"Cannot find role for XP grade '{xp_grade}'") + self.logger.error(err, extra={**extra, "xp_grade": xp_grade}) + raise err + + member = await self.get_guild_member(discord_id, bot) + await self.swap_role_in_group( + member, rank_role_id, bot.role_manager.get_group_ids("xp_rank"), bot + ) + await self.swap_role_in_group( + member, grade_role_id, bot.role_manager.get_group_ids("xp_grade"), bot + ) + return self.success() + async def _handle_account_deleted(self, body: WebhookBody, bot: Bot) -> dict: """ Handles the account deleted event. diff --git a/src/webhooks/types.py b/src/webhooks/types.py index e260412..ed594e0 100644 --- a/src/webhooks/types.py +++ b/src/webhooks/types.py @@ -14,6 +14,7 @@ class WebhookEvent(Enum): SUBSCRIPTION_CHANGE = "SubscriptionChange" NAME_CHANGE = "NameChange" SEASON_RANK_CHANGE = "SeasonRankChange" + XP_RANK_CHANGE = "XpRankChange" class Platform(Enum): diff --git a/tests/helpers.py b/tests/helpers.py index 9a7f775..d8ff08b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -301,6 +301,12 @@ def get_rank_role_id(self, rank_name): def get_season_role_id(self, tier): return None + def get_xp_rank_role_id(self, rank_name): + return None + + def get_xp_grade_role_id(self, grade): + return None + def get_post_or_rank(self, what): return None diff --git a/tests/src/webhooks/handlers/test_account.py b/tests/src/webhooks/handlers/test_account.py index e18cf7e..9af977b 100644 --- a/tests/src/webhooks/handlers/test_account.py +++ b/tests/src/webhooks/handlers/test_account.py @@ -11,6 +11,14 @@ from tests import helpers +def _make_role_manager(**overrides): + """Create a MockRoleManager with optional method overrides.""" + rm = helpers.MockRoleManager() + for attr, val in overrides.items(): + setattr(rm, attr, val if callable(val) else lambda *a, v=val, **kw: v) + return rm + + class TestAccountHandler: """Test the `AccountHandler` class.""" @@ -481,6 +489,229 @@ async def test_handle_account_banned_success(self, bot): mock_log.assert_called() assert result == handler.success() + @pytest.mark.asyncio + async def test_handle_xp_rank_change_event(self, bot): + """Test handle method routes XP_RANK_CHANGE event correctly.""" + handler = AccountHandler() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.XP_RANK_CHANGE, + properties={ + "discord_id": 123456789, + "account_id": 987654321, + "xp_rank": "Skilled", + "xp_grade": "III", + }, + traits={}, + ) + + with patch.object( + handler, "_handle_xp_rank_change", new_callable=AsyncMock + ) as mock_handle: + await handler.handle(body, bot) + mock_handle.assert_called_once_with(body, bot) + + @pytest.mark.asyncio + async def test_handle_xp_rank_change_assign_from_none(self, bot): + """Member with no XP roles gets both a tier and a grade role added.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + mock_member = helpers.MockMember(id=discord_id) + mock_member.roles = [] + mock_member.add_roles = AsyncMock() + mock_member.remove_roles = AsyncMock() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.XP_RANK_CHANGE, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "xp_rank": "Skilled", + "xp_grade": "III", + }, + traits={}, + ) + handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) + handler.validate_property = MagicMock(side_effect=lambda value, name: value) + handler.get_guild_member = AsyncMock(return_value=mock_member) + + role_tier = MagicMock(id=555) + role_tier_other = MagicMock(id=666) + role_grade = MagicMock(id=30) + role_grade_other = MagicMock(id=20) + roles = {555: role_tier, 666: role_tier_other, 20: role_grade_other, 30: role_grade} + groups = {"xp_rank": [555, 666], "xp_grade": [20, 30]} + bot.role_manager = _make_role_manager( + get_xp_rank_role_id=lambda rank: 555, + get_xp_grade_role_id=lambda grade: 30, + get_group_ids=lambda cat: groups[cat], + ) + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.side_effect = lambda rid: roles.get(rid) + bot.guilds = [mock_guild] + + result = await handler._handle_xp_rank_change(body, bot) + assert mock_member.add_roles.await_count == 2 + mock_member.add_roles.assert_any_await(role_tier, atomic=True) + mock_member.add_roles.assert_any_await(role_grade, atomic=True) + mock_member.remove_roles.assert_not_awaited() + assert result == handler.success() + + @pytest.mark.asyncio + async def test_handle_xp_rank_change_swaps_both_groups(self, bot): + """Existing tier and grade roles are each swapped within their own group.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + role_tier = MagicMock(id=555) + role_tier_old = MagicMock(id=666) + role_grade = MagicMock(id=30) + role_grade_old = MagicMock(id=20) + mock_member = helpers.MockMember(id=discord_id) + mock_member.roles = [role_tier_old, role_grade_old] + mock_member.add_roles = AsyncMock() + mock_member.remove_roles = AsyncMock() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.XP_RANK_CHANGE, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "xp_rank": "Skilled", + "xp_grade": "III", + }, + traits={}, + ) + handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) + handler.validate_property = MagicMock(side_effect=lambda value, name: value) + handler.get_guild_member = AsyncMock(return_value=mock_member) + + roles = {555: role_tier, 666: role_tier_old, 20: role_grade_old, 30: role_grade} + groups = {"xp_rank": [555, 666], "xp_grade": [20, 30]} + bot.role_manager = _make_role_manager( + get_xp_rank_role_id=lambda rank: 555, + get_xp_grade_role_id=lambda grade: 30, + get_group_ids=lambda cat: groups[cat], + ) + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.side_effect = lambda rid: roles.get(rid) + bot.guilds = [mock_guild] + + result = await handler._handle_xp_rank_change(body, bot) + mock_member.remove_roles.assert_any_await(role_tier_old, atomic=True) + mock_member.remove_roles.assert_any_await(role_grade_old, atomic=True) + mock_member.add_roles.assert_any_await(role_tier, atomic=True) + mock_member.add_roles.assert_any_await(role_grade, atomic=True) + assert result == handler.success() + + @pytest.mark.asyncio + async def test_handle_xp_rank_change_grade_independent_of_tier(self, bot): + """Changing only the grade leaves the existing (unchanged) tier role intact.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + role_tier = MagicMock(id=555) + role_tier_other = MagicMock(id=666) + role_grade = MagicMock(id=30) + role_grade_old = MagicMock(id=20) + mock_member = helpers.MockMember(id=discord_id) + # Member already holds the target tier role and the old grade role. + mock_member.roles = [role_tier, role_grade_old] + mock_member.add_roles = AsyncMock() + mock_member.remove_roles = AsyncMock() + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.XP_RANK_CHANGE, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "xp_rank": "Skilled", + "xp_grade": "III", + }, + traits={}, + ) + handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) + handler.validate_property = MagicMock(side_effect=lambda value, name: value) + handler.get_guild_member = AsyncMock(return_value=mock_member) + + roles = {555: role_tier, 666: role_tier_other, 20: role_grade_old, 30: role_grade} + groups = {"xp_rank": [555, 666], "xp_grade": [20, 30]} + bot.role_manager = _make_role_manager( + get_xp_rank_role_id=lambda rank: 555, + get_xp_grade_role_id=lambda grade: 30, + get_group_ids=lambda cat: groups[cat], + ) + mock_guild = helpers.MockGuild(id=1) + mock_guild.get_role.side_effect = lambda rid: roles.get(rid) + bot.guilds = [mock_guild] + + result = await handler._handle_xp_rank_change(body, bot) + # Tier unchanged: no add/remove of the tier role. + mock_member.remove_roles.assert_awaited_once_with(role_grade_old, atomic=True) + mock_member.add_roles.assert_awaited_once_with(role_grade, atomic=True) + assert result == handler.success() + + @pytest.mark.asyncio + async def test_handle_xp_rank_change_unknown_rank(self, bot): + """Unknown XP rank (no role configured) raises ValueError.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + mock_member = helpers.MockMember(id=discord_id) + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.XP_RANK_CHANGE, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "xp_rank": "Nonexistent", + "xp_grade": "III", + }, + traits={}, + ) + handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) + handler.validate_property = MagicMock(side_effect=lambda value, name: value) + handler.get_guild_member = AsyncMock(return_value=mock_member) + + bot.role_manager = _make_role_manager( + get_xp_rank_role_id=lambda rank: None, + get_xp_grade_role_id=lambda grade: 30, + ) + + with pytest.raises(ValueError, match="Cannot find role for XP rank"): + await handler._handle_xp_rank_change(body, bot) + + @pytest.mark.asyncio + async def test_handle_xp_rank_change_unknown_grade(self, bot): + """Unknown XP grade (no role configured) raises ValueError.""" + handler = AccountHandler() + discord_id = 123456789 + account_id = 987654321 + mock_member = helpers.MockMember(id=discord_id) + body = WebhookBody( + platform=Platform.ACCOUNT, + event=WebhookEvent.XP_RANK_CHANGE, + properties={ + "discord_id": discord_id, + "account_id": account_id, + "xp_rank": "Skilled", + "xp_grade": "Nonexistent", + }, + traits={}, + ) + handler.validate_common_properties = MagicMock(return_value=(discord_id, account_id)) + handler.validate_property = MagicMock(side_effect=lambda value, name: value) + handler.get_guild_member = AsyncMock(return_value=mock_member) + + bot.role_manager = _make_role_manager( + get_xp_rank_role_id=lambda rank: 555, + get_xp_grade_role_id=lambda grade: None, + ) + + with pytest.raises(ValueError, match="Cannot find role for XP grade"): + await handler._handle_xp_rank_change(body, bot) + @pytest.mark.asyncio async def test_handle_account_banned_member_not_found(self, bot): """Test account banned event when member is not found in guild."""