From da0b3f6c7b7f36a0eadf033587a83df880651c0f Mon Sep 17 00:00:00 2001 From: espdesign Date: Wed, 23 Sep 2026 22:38:47 -0400 Subject: [PATCH] feat(auth): unified team lead authorization for roles and individual members - Add guild_lead_users table and Alembic migration 0003_add_guild_lead_users - Implement IGuildLeadUserRepository port, Postgres repo adapter, and UoW wiring - Expand AuthService.can_manage_projects to authorize individual team lead members - Add /pm admin lead slash command supporting both role and user assignments (with /pm admin lead-role alias) - Update interactive admin UI to TeamLeadsAdminView with RoleSelect and UserSelect pickers - Auto-prune individual lead records on member departure in on_member_remove - Update wiki documentation for teams, authorization matrix, and slash commands - Add unit, repository, cog, and menu tests Closes #44 --- docs/wiki/Slash-Commands-Reference.md | 18 +-- docs/wiki/Teams-and-Authorization.md | 49 +++--- .../versions/0003_add_guild_lead_users.py | 34 ++++ src/adapters/db/postgres_repo.py | 39 +++++ src/adapters/db/tables.py | 8 + src/adapters/db/unit_of_work.py | 4 + src/adapters/discord_bot/bot.py | 44 +++-- src/adapters/discord_bot/cogs/pm_cog.py | 124 +++++++++++++++ src/adapters/discord_bot/views/admin_menu.py | 150 ++++++++++++++++-- .../discord_bot/views/forum_helpers.py | 15 +- src/adapters/discord_bot/views/hub_menu.py | 20 ++- src/main.py | 3 + src/ports/repositories.py | 14 ++ src/ports/unit_of_work.py | 2 + src/services/auth_service.py | 39 ++++- tests/conftest.py | 7 +- tests/test_authorization.py | 105 +++++++++++- tests/test_cogs.py | 102 ++++++++++++ tests/test_menus.py | 94 ++++++++++- tests/test_repos.py | 33 ++++ 20 files changed, 826 insertions(+), 78 deletions(-) create mode 100644 src/adapters/db/migrations/versions/0003_add_guild_lead_users.py diff --git a/docs/wiki/Slash-Commands-Reference.md b/docs/wiki/Slash-Commands-Reference.md index 483ae85..0f273c5 100644 --- a/docs/wiki/Slash-Commands-Reference.md +++ b/docs/wiki/Slash-Commands-Reference.md @@ -37,16 +37,16 @@ All DGG-PM bot commands are grouped under the single `/pm` top-level namespace t | Subcommand | Parameters | Description | Permission Required | | :--- | :--- | :--- | :--- | -| **`create`** | `name` (required)
`prefix` (required)
`role` (required: `@Role`)
`channel` (optional)
`description` (optional)
`category` (optional) | Creates a project container, maps the squad Discord role, and automatically provisions standard tags + pinned Control Hub if a forum channel is linked. | `Manage Server` OR Authorized Team Lead Role | +| **`create`** | `name` (required)
`prefix` (required)
`role` (required: `@Role`)
`channel` (optional)
`description` (optional)
`category` (optional) | Creates a project container, maps the squad Discord role, and automatically provisions standard tags + pinned Control Hub if a forum channel is linked. | `Manage Server` OR Authorized Team Lead (Role/Member) | | **`tree`** | `project_name` (required)
`orientation` (optional: `lr`, `tb`) | Renders the interactive visual dependency graph for the project. | `@everyone` | -| **`role`** | `project_name` (required)
`role` (required: `@Role`)
`action` (required: `add`, `remove`) | Maps or unmaps additional Discord squad roles to a project container (for cross-functional squads). | `Manage Server` OR Authorized Team Lead Role | -| **`squad`** | `project_name` (required)
`squad_name` (required)
`action` (required: `add`, `remove`) | Maps or unmaps a functional squad to a project container (alias: `team`). | `Manage Server` OR Authorized Team Lead Role | -| **`lead`** | `project_name` (required)
`user` (required: `@Member`)
`action` (required: `add`, `remove`) | Designates or removes a Squad Lead for the project's squads. | `Manage Server` OR Authorized Team Lead Role OR Active Squad Lead | +| **`role`** | `project_name` (required)
`role` (required: `@Role`)
`action` (required: `add`, `remove`) | Maps or unmaps additional Discord squad roles to a project container (for cross-functional squads). | `Manage Server` OR Authorized Team Lead (Role/Member) | +| **`squad`** | `project_name` (required)
`squad_name` (required)
`action` (required: `add`, `remove`) | Maps or unmaps a functional squad to a project container (alias: `team`). | `Manage Server` OR Authorized Team Lead (Role/Member) | +| **`lead`** | `project_name` (required)
`user` (required: `@Member`)
`action` (required: `add`, `remove`) | Designates or removes a Squad Lead for the project's squads. | `Manage Server` OR Authorized Team Lead (Role/Member) OR Active Squad Lead | | **`list`** | None | Lists all active project containers and their bound Discord channels. | `@everyone` | -| **`archive`** | `project_name` (required) | Archives a project container and cascades thread archiving. | `Manage Server` OR Authorized Team Lead Role | -| **`unarchive`** | `project_name` (required) | Restores an archived project container and reopens task threads. | `Manage Server` OR Authorized Team Lead Role | -| **`setup_forum`** | `forum` (required) | Automatically configures standard PM tags on a Discord Forum Channel. | `Manage Server` OR Authorized Team Lead Role | -| **`rebuild`** | `project_name` (required)
`forum` (optional: `#Channel`) | Reconstructs and reconciles a project's Discord presence (forum channel, tags, control hub, and task thread workspaces) from database state. | `Manage Server` OR Authorized Team Lead Role | +| **`archive`** | `project_name` (required) | Archives a project container and cascades thread archiving. | `Manage Server` OR Authorized Team Lead (Role/Member) | +| **`unarchive`** | `project_name` (required) | Restores an archived project container and reopens task threads. | `Manage Server` OR Authorized Team Lead (Role/Member) | +| **`setup_forum`** | `forum` (required) | Automatically configures standard PM tags on a Discord Forum Channel. | `Manage Server` OR Authorized Team Lead (Role/Member) | +| **`rebuild`** | `project_name` (required)
`forum` (optional: `#Channel`) | Reconstructs and reconciles a project's Discord presence (forum channel, tags, control hub, and task thread workspaces) from database state. | `Manage Server` OR Authorized Team Lead (Role/Member) | --- @@ -54,7 +54,7 @@ All DGG-PM bot commands are grouped under the single `/pm` top-level namespace t | Subcommand | Parameters | Description | Permission Required | | :--- | :--- | :--- | :--- | -| **`lead-role`** | `action` (required: `add`, `remove`, `list`)
`role` (optional: `@Role`) | Registers, removes, or lists Discord server roles authorized as Team Leads for project and squad management. | `Manage Server` | +| **`lead`** | `action` (required: `add`, `remove`, `list`)
`role` (optional: `@Role`)
`user` (optional: `@Member`) | Registers, removes, or lists Discord server roles or individual members authorized as Team Leads for project and squad management. (Alias: `lead-role`). | `Manage Server` | | **`sync`** | `scope` (optional: `guild`, `global`) | Synchronizes application slash commands with Discord on demand without restarting the bot. | `Manage Server` | diff --git a/docs/wiki/Teams-and-Authorization.md b/docs/wiki/Teams-and-Authorization.md index 29b48d2..680a92d 100644 --- a/docs/wiki/Teams-and-Authorization.md +++ b/docs/wiki/Teams-and-Authorization.md @@ -9,13 +9,13 @@ DGG-PM implements a Discord-native permission model. Discord server roles act as ``` ┌────────────────────────────────────────────────────────┐ │ Server Managers (Manage Server / Administrator) │ -│ - Full server bypass; configure Team Lead roles │ +│ - Full server bypass; configure Team Leads │ └──────────────────────────┬─────────────────────────────┘ │ ┌──────────────────────────▼─────────────────────────────┐ -│ Authorized Team Lead Roles & Active Squad Leads │ +│ Authorized Team Leads (Roles or Members) & Squad Leads│ │ - Create and manage projects, channels, & squads │ -│ - Granted via server role without Discord admin perms │ +│ - Granted via server role or member without admin perms│ └──────────────────────────┬─────────────────────────────┘ │ ┌──────────────────────────▼─────────────────────────────┐ @@ -38,9 +38,9 @@ DGG-PM implements a Discord-native permission model. Discord server roles act as ## 🛡️ Mutation Authorization Matrix -| Action | Server Manager | Authorized Team Lead Role | Squad Lead | Squad Member (Mapped Role) | Task Assignee | Task Creator | Other Server Member | +| Action | Server Manager | Authorized Team Lead (Role/Member) | Squad Lead | Squad Member (Mapped Role) | Task Assignee | Task Creator | Other Server Member | | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| **Configure Lead Roles** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| **Configure Team Leads** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | **Create Project / Squad** | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | | **Manage Project (Role, Archive)** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | | **Designate / Remove Squad Lead** | ✅ | ✅ | ✅ (Own Squad) | ❌ | ❌ | ❌ | ❌ | @@ -51,33 +51,44 @@ DGG-PM implements a Discord-native permission model. Discord server roles act as --- -## 🎖️ Configurable Team Lead Roles +## 🎖️ Configurable Team Leads (Roles & Individual Members) -To adhere to the principle of least privilege, DGG-PM allows server managers to authorize specific Discord server roles as **Team Lead roles**. Members holding these roles can create and manage project containers, map squads, bind channels, and designate leads **without requiring server-wide `Manage Server` or `Administrator` permissions**. +To adhere to the principle of least privilege, DGG-PM allows server managers to authorize specific Discord server roles **or individual server members** as **Team Leads**. Authorized Team Leads can create and manage project containers, map squads, bind channels, and designate leads **without requiring server-wide `Manage Server` or `Administrator` permissions**. ### 1. Interactive Admin Dashboard (`/pm menu`) 1. Open the project dashboard using `/pm menu`. -2. Members with `Manage Server` or `Administrator` permissions will see a **`Lead Roles`** button in the top action row. -3. Clicking **`Lead Roles`** opens the interactive role management portal: - - Displays all currently authorized Team Lead roles in an embed list. +2. Members with `Manage Server` or `Administrator` permissions will see a **`Team Leads`** button in the top action row. +3. Clicking **`Team Leads`** opens the unified management portal: + - Displays all currently authorized Team Lead roles and individual members in an embed list. - Includes a native Discord **Role Select** dropdown to select any server role. - - Provides 1-click **`Assign Role`** and **`Remove Role`** actions. + - Includes a native Discord **User Select** dropdown to select any server member (bots excluded). + - Provides 1-click **`Assign Role`**, **`Remove Role`**, **`Assign User`**, and **`Remove User`** actions. - Includes a **`Back to Dashboard`** button to return cleanly. -### 2. Slash Command Management (`/pm admin lead-role`) -Server Managers can also configure authorized roles via slash commands: -- **Add a Lead Role**: +### 2. Slash Command Management (`/pm admin lead`) +Server Managers can configure authorized roles and individual members via slash commands: +- **Add / Remove a Team Lead Role**: ```text - /pm admin lead-role action:add role:@Engineering Lead + /pm admin lead action:add role:@Engineering Lead + /pm admin lead action:remove role:@Engineering Lead ``` -- **Remove a Lead Role**: +- **Add / Remove an Individual Team Lead Member**: ```text - /pm admin lead-role action:remove role:@Engineering Lead + /pm admin lead action:add user:@Alice + /pm admin lead action:remove user:@Alice ``` -- **List All Lead Roles**: +- **List All Authorized Team Leads (Roles and Members)**: ```text - /pm admin lead-role action:list + /pm admin lead action:list ``` +- *(Backwards compatibility)*: `/pm admin lead-role` is retained as an alias for `/pm admin lead`. + +--- + +## 🔄 Self-Healing & Departure Lifecycle + +1. **Individual Team Lead Departure**: + - When a member leaves the Discord server (`on_member_remove`), DGG-PM automatically prunes their entry from `guild_lead_users`, preventing stale authorizations if the user ever rejoins. --- diff --git a/src/adapters/db/migrations/versions/0003_add_guild_lead_users.py b/src/adapters/db/migrations/versions/0003_add_guild_lead_users.py new file mode 100644 index 0000000..8ee80f9 --- /dev/null +++ b/src/adapters/db/migrations/versions/0003_add_guild_lead_users.py @@ -0,0 +1,34 @@ +"""add_guild_lead_users + +Revision ID: 0003 +Revises: 0002 +Create Date: 2026-09-24 02:17:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0003" +down_revision: str | Sequence[str] | None = "0002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "guild_lead_users", + sa.Column("guild_id", sa.BigInteger(), nullable=False), + sa.Column("user_discord_id", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("guild_id", "user_discord_id"), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table("guild_lead_users") diff --git a/src/adapters/db/postgres_repo.py b/src/adapters/db/postgres_repo.py index a89a88c..41f79ac 100644 --- a/src/adapters/db/postgres_repo.py +++ b/src/adapters/db/postgres_repo.py @@ -12,6 +12,7 @@ from src.adapters.db.tables import ( GuildLeadRoleTable, + GuildLeadUserTable, OutboxEventTable, ProjectSquadTable, ProjectTable, @@ -44,6 +45,7 @@ ) from src.ports.repositories import ( IGuildLeadRoleRepository, + IGuildLeadUserRepository, IOutboxRepo, IProjectRepo, ISquadRepo, @@ -1320,3 +1322,40 @@ async def list_lead_role_ids(self, guild_id: int) -> set[int]: stmt = select(GuildLeadRoleTable.discord_role_id).where(GuildLeadRoleTable.guild_id == guild_id) res = await session.execute(stmt) return set(res.scalars().all()) + + +class PostgresGuildLeadUserRepository(BasePostgresRepo, IGuildLeadUserRepository): + async def add_lead_user(self, guild_id: int, user_discord_id: int) -> None: + async with self._get_session() as session: + stmt = select(GuildLeadUserTable).where( + GuildLeadUserTable.guild_id == guild_id, + GuildLeadUserTable.user_discord_id == user_discord_id, + ) + res = await session.execute(stmt) + if res.scalar_one_or_none() is not None: + return + row = GuildLeadUserTable(guild_id=guild_id, user_discord_id=user_discord_id) + session.add(row) + if self._should_commit(None): + await session.commit() + else: + await session.flush() + + async def remove_lead_user(self, guild_id: int, user_discord_id: int) -> bool: + async with self._get_session() as session: + stmt = delete(GuildLeadUserTable).where( + GuildLeadUserTable.guild_id == guild_id, + GuildLeadUserTable.user_discord_id == user_discord_id, + ) + res = await session.execute(stmt) + if self._should_commit(None): + await session.commit() + else: + await session.flush() + return bool(res.rowcount and res.rowcount > 0) + + async def list_lead_user_ids(self, guild_id: int) -> set[int]: + async with self._get_session() as session: + stmt = select(GuildLeadUserTable.user_discord_id).where(GuildLeadUserTable.guild_id == guild_id) + res = await session.execute(stmt) + return set(res.scalars().all()) diff --git a/src/adapters/db/tables.py b/src/adapters/db/tables.py index f024add..f8d57a6 100644 --- a/src/adapters/db/tables.py +++ b/src/adapters/db/tables.py @@ -200,3 +200,11 @@ class GuildLeadRoleTable(Base): guild_id = Column(BigInteger, primary_key=True) discord_role_id = Column(BigInteger, primary_key=True) created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)) + + +class GuildLeadUserTable(Base): + __tablename__ = "guild_lead_users" + + guild_id = Column(BigInteger, primary_key=True) + user_discord_id = Column(BigInteger, primary_key=True) + created_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC)) diff --git a/src/adapters/db/unit_of_work.py b/src/adapters/db/unit_of_work.py index 4aa74cf..980ef98 100644 --- a/src/adapters/db/unit_of_work.py +++ b/src/adapters/db/unit_of_work.py @@ -7,6 +7,7 @@ from src.adapters.db.postgres_repo import ( PostgresGuildLeadRoleRepository, + PostgresGuildLeadUserRepository, PostgresOutboxRepo, PostgresProjectRepo, PostgresSquadRepo, @@ -15,6 +16,7 @@ ) from src.ports.repositories import ( IGuildLeadRoleRepository, + IGuildLeadUserRepository, IOutboxRepo, IProjectRepo, ISquadRepo, @@ -38,6 +40,7 @@ def __init__(self, session_factory: async_sessionmaker[AsyncSession]): self.outbox: IOutboxRepo = None # type: ignore[assignment] self.user_prefs: IUserPreferenceRepo = None # type: ignore[assignment] self.guild_lead_roles: IGuildLeadRoleRepository = None # type: ignore[assignment] + self.guild_lead_users: IGuildLeadUserRepository = None # type: ignore[assignment] @property def session_factory(self) -> async_sessionmaker[AsyncSession]: @@ -51,6 +54,7 @@ async def __aenter__(self) -> SqlAlchemyUnitOfWork: self.outbox = PostgresOutboxRepo(self._session) self.user_prefs = PostgresUserPreferenceRepo(self._session) self.guild_lead_roles = PostgresGuildLeadRoleRepository(self._session) + self.guild_lead_users = PostgresGuildLeadUserRepository(self._session) return self async def __aexit__( diff --git a/src/adapters/discord_bot/bot.py b/src/adapters/discord_bot/bot.py index 112265b..822713d 100644 --- a/src/adapters/discord_bot/bot.py +++ b/src/adapters/discord_bot/bot.py @@ -17,7 +17,7 @@ ) from src.config import settings from src.domain.models import Task -from src.ports.repositories import IGuildLeadRoleRepository +from src.ports.repositories import IGuildLeadRoleRepository, IGuildLeadUserRepository from src.services.auth_service import AuthService from src.services.outbox_service import OutboxService from src.services.project_service import ProjectService @@ -39,6 +39,7 @@ def __init__( workspace: ITaskDiscordWorkspace | None = None, project_workspace: IProjectDiscordWorkspace | None = None, guild_lead_role_repo: IGuildLeadRoleRepository | None = None, + guild_lead_user_repo: IGuildLeadUserRepository | None = None, auth_service: AuthService | None = None, ): intents = discord.Intents.default() @@ -59,12 +60,16 @@ def __init__( getattr(task_service, "outbox_service", None) if task_service else None ) self.guild_lead_role_repo = guild_lead_role_repo + self.guild_lead_user_repo = guild_lead_user_repo self._background_tasks: set[asyncio.Task] = set() if auth_service is not None: self.auth_service = auth_service elif project_service and self.squad_service: self.auth_service = AuthService( - project_service, self.squad_service, guild_lead_role_repo=guild_lead_role_repo + project_service, + self.squad_service, + guild_lead_role_repo=guild_lead_role_repo, + guild_lead_user_repo=guild_lead_user_repo, ) else: self.auth_service = None @@ -284,22 +289,33 @@ async def on_member_update(self, before: discord.Member, after: discord.Member) logger.warning("Error auto-pruning squad lead on member update for user %s: %s", after.id, e) async def on_member_remove(self, member: discord.Member) -> None: - """Auto-prune squad lead records across all squads in the guild if a member leaves the server.""" - if not self.squad_service: - return + """Auto-prune squad lead and individual team lead records across the guild if a member leaves the server.""" + if self.squad_service: + try: + squads = await self.squad_service.list_squads(member.guild.id) + for squad in squads: + if await self.squad_service.is_squad_lead(squad.id, member.id): + await self.squad_service.remove_squad_lead(squad.id, member.id) + logger.info( + "Auto-pruned squad lead record for user %s from squad '%s' because member left the server", + member.id, + squad.name, + ) + except Exception as e: + logger.warning("Error auto-pruning squad leads on member remove for user %s: %s", member.id, e) - try: - squads = await self.squad_service.list_squads(member.guild.id) - for squad in squads: - if await self.squad_service.is_squad_lead(squad.id, member.id): - await self.squad_service.remove_squad_lead(squad.id, member.id) + if self.auth_service: + try: + removed = await self.auth_service.remove_guild_lead_user(member.guild.id, member.id) + if removed: logger.info( - "Auto-pruned squad lead record for user %s from squad '%s' because member left the server", + "Auto-pruned individual team lead record for user %s from guild %s " + "because member left the server", member.id, - squad.name, + member.guild.id, ) - except Exception as e: - logger.warning("Error auto-pruning squad leads on member remove for user %s: %s", member.id, e) + except Exception as e: + logger.warning("Error auto-pruning individual team lead on member remove for user %s: %s", member.id, e) async def on_interaction(self, interaction: discord.Interaction) -> None: """Global interaction dispatcher handling dynamic persistent task buttons across restarts.""" diff --git a/src/adapters/discord_bot/cogs/pm_cog.py b/src/adapters/discord_bot/cogs/pm_cog.py index 71bfc0f..fd1e0bf 100644 --- a/src/adapters/discord_bot/cogs/pm_cog.py +++ b/src/adapters/discord_bot/cogs/pm_cog.py @@ -501,6 +501,130 @@ async def admin_retry_outbox( ephemeral=True, ) + @admin_group.command( + name="lead", + description="Authorize, revoke, or list Team Lead roles and individual members", + ) + @app_commands.describe( + action="Action to perform (add, remove, or list authorized Team Leads)", + role="Discord role to authorize or revoke", + user="Discord member to authorize or revoke", + ) + @app_commands.choices( + action=[ + app_commands.Choice(name="Add Team Lead(s)", value="add"), + app_commands.Choice(name="Remove Team Lead(s)", value="remove"), + app_commands.Choice(name="List Team Leads", value="list"), + ] + ) + @app_commands.checks.has_permissions(manage_guild=True) + async def admin_lead( + self, + interaction: discord.Interaction, + action: str, + role: discord.Role | None = None, + user: discord.Member | None = None, + ) -> None: + """Authorize or revoke Discord roles and individual members to create/manage projects and squads.""" + if not interaction.guild: + return + await interaction.response.defer(ephemeral=True) + try: + guild_id = interaction.guild.id + if action in ("add", "remove"): + if not role and not user: + await interaction.followup.send( + "❌ Please specify a role, a member, or both to add/remove.", + ephemeral=True, + ) + return + + if user and getattr(user, "bot", False): + await interaction.followup.send( + "❌ Bots cannot be authorized as Team Leads.", + ephemeral=True, + ) + return + + messages: list[str] = [] + if action == "add": + if role: + await self.auth_service.add_guild_lead_role(guild_id, role.id) + messages.append(f"⭐ **Added Team Lead Role**: <@&{role.id}>") + if user: + await self.auth_service.add_guild_lead_user(guild_id, user.id) + messages.append(f"⭐ **Added Team Lead Member**: <@{user.id}>") + messages.append("They are now authorized to create and manage projects and squads.") + await interaction.followup.send("\n".join(messages), ephemeral=True) + else: # remove + if role: + removed_role = await self.auth_service.remove_guild_lead_role(guild_id, role.id) + if removed_role: + messages.append(f"✅ **Removed Team Lead Role**: <@&{role.id}>") + else: + messages.append(f"ℹ️ <@&{role.id}> was not registered as an authorized Team Lead role.") + if user: + removed_user = await self.auth_service.remove_guild_lead_user(guild_id, user.id) + if removed_user: + messages.append(f"✅ **Removed Team Lead Member**: <@{user.id}>") + else: + messages.append(f"ℹ️ <@{user.id}> was not registered as an authorized Team Lead member.") + await interaction.followup.send("\n".join(messages), ephemeral=True) + + elif action == "list": + role_ids = await self.auth_service.list_guild_lead_roles(guild_id) + user_ids = await self.auth_service.list_guild_lead_users(guild_id) + + if not role_ids and not user_ids: + await interaction.followup.send( + "ℹ️ **No Team Leads Configured**\n" + "Only Discord Server Managers (users with `Manage Server` or `Administrator`) " + "can create and manage projects.", + ephemeral=True, + ) + return + + embed = discord.Embed( + title="👥 Authorized Team Leads", + description=( + "The following Discord roles and individual members are authorized to create " + "and manage projects and squads without requiring server-wide `Manage Server` permissions:\n" + ), + color=discord.Color.blue(), + ) + + if role_ids: + role_lines = [f"• <@&{rid}> (`{rid}`)" for rid in sorted(role_ids)] + embed.add_field( + name=f"Configured Roles ({len(role_ids)})", + value="\n".join(role_lines), + inline=False, + ) + else: + embed.add_field( + name="Configured Roles (0)", + value="*No roles configured*", + inline=False, + ) + + if user_ids: + user_lines = [f"• <@{uid}> (`{uid}`)" for uid in sorted(user_ids)] + embed.add_field( + name=f"Configured Members ({len(user_ids)})", + value="\n".join(user_lines), + inline=False, + ) + else: + embed.add_field( + name="Configured Members (0)", + value="*No individual members configured*", + inline=False, + ) + + await interaction.followup.send(embed=embed, ephemeral=True) + except Exception as e: + await send_interaction_error(interaction, e, "managing team leads", logger, ephemeral=True) + @admin_group.command( name="lead-role", description="Authorize or revoke an existing Discord Team Lead role to create and manage projects", diff --git a/src/adapters/discord_bot/views/admin_menu.py b/src/adapters/discord_bot/views/admin_menu.py index 5875e64..d634bb3 100644 --- a/src/adapters/discord_bot/views/admin_menu.py +++ b/src/adapters/discord_bot/views/admin_menu.py @@ -174,8 +174,8 @@ async def _on_back_clicked(self, interaction: discord.Interaction) -> None: await interaction.response.edit_message(content=None, embed=embed, view=view) -class LeadRolesAdminView(BaseView): - """View allowing Server Managers to assign, view, and remove Team Lead roles for the guild.""" +class TeamLeadsAdminView(BaseView): + """View allowing Server Managers to assign, view, and remove Team Leads (roles and members) for the guild.""" def __init__( self, @@ -194,6 +194,7 @@ def __init__( self.auth_service = auth_service self._initial_interaction = initial_interaction self.selected_role_id: int | None = None + self.selected_user_id: int | None = None self._rebuild_items() def _rebuild_items(self) -> None: @@ -209,7 +210,7 @@ def _rebuild_items(self) -> None: self.role_select.callback = self._on_role_selected self.add_item(self.role_select) - # Row 1: Action buttons + # Row 1: Role Action buttons self.assign_btn = discord.ui.Button( label="Assign Role", style=discord.ButtonStyle.success, @@ -217,6 +218,7 @@ def _rebuild_items(self) -> None: ) self.assign_btn.callback = self._on_assign_clicked self.add_item(self.assign_btn) + self.assign_role_btn = self.assign_btn self.remove_btn = discord.ui.Button( label="Remove Role", @@ -225,11 +227,40 @@ def _rebuild_items(self) -> None: ) self.remove_btn.callback = self._on_remove_clicked self.add_item(self.remove_btn) + self.remove_role_btn = self.remove_btn + # Row 2: Select Discord User + self.user_select = discord.ui.UserSelect( + placeholder="Select a Discord member to assign or remove...", + min_values=1, + max_values=1, + row=2, + ) + self.user_select.callback = self._on_user_selected + self.add_item(self.user_select) + + # Row 3: User Action buttons + self.assign_user_btn = discord.ui.Button( + label="Assign Member", + style=discord.ButtonStyle.success, + row=3, + ) + self.assign_user_btn.callback = self._on_assign_user_clicked + self.add_item(self.assign_user_btn) + + self.remove_user_btn = discord.ui.Button( + label="Remove Member", + style=discord.ButtonStyle.danger, + row=3, + ) + self.remove_user_btn.callback = self._on_remove_user_clicked + self.add_item(self.remove_user_btn) + + # Row 4: Navigation self.back_btn = discord.ui.Button( label="Back to Dashboard", style=discord.ButtonStyle.secondary, - row=1, + row=4, ) self.back_btn.callback = self._on_back_clicked self.add_item(self.back_btn) @@ -242,16 +273,26 @@ async def _on_role_selected(self, interaction: discord.Interaction) -> None: embed = await self.build_embed(interaction.guild) await interaction.response.edit_message(embed=embed, view=self) + async def _on_user_selected(self, interaction: discord.Interaction) -> None: + selected_values = getattr(self.user_select, "values", []) or getattr(self.user_select, "_values", []) + if selected_values: + val = selected_values[0] + self.selected_user_id = int(val.id) if hasattr(val, "id") else int(val) + embed = await self.build_embed(interaction.guild) + await interaction.response.edit_message(embed=embed, view=self) + async def build_embed(self, guild: discord.Guild | None) -> discord.Embed: guild_id = guild.id if guild else None role_ids: set[int] = set() + user_ids: set[int] = set() if self.auth_service and guild_id: role_ids = await self.auth_service.list_guild_lead_roles(guild_id) + user_ids = await self.auth_service.list_guild_lead_users(guild_id) embed = discord.Embed( - title="👥 Authorized Team Lead Roles", + title="👥 Authorized Team Leads", description=( - "Members holding any of the configured roles can create and manage " + "Members holding any of the configured roles or designated individually can create and manage " "projects and squads without requiring server-wide `Manage Server` permissions.\n\n" ), color=discord.Color.blue(), @@ -273,7 +314,27 @@ async def build_embed(self, guild: discord.Guild | None) -> discord.Embed: else: embed.add_field( name="Configured Roles (0)", - value="*No team lead roles configured yet. Use the picker below to assign one.*", + value="*No team lead roles configured yet. Use the role picker to assign one.*", + inline=False, + ) + + if user_ids: + user_lines = [] + for uid in sorted(user_ids): + member = guild.get_member(uid) if guild else None + if member: + user_lines.append(f"• **@{member.display_name}** (`{uid}`)") + else: + user_lines.append(f"• <@{uid}> (`{uid}`)") + embed.add_field( + name=f"Configured Members ({len(user_ids)})", + value="\n".join(user_lines), + inline=False, + ) + else: + embed.add_field( + name="Configured Members (0)", + value="*No individual team lead members configured yet. Use the member picker to assign one.*", inline=False, ) @@ -287,7 +348,17 @@ async def build_embed(self, guild: discord.Guild | None) -> discord.Embed: inline=False, ) - embed.set_footer(text="dgg-pm • Team Lead Role Administration") + if self.selected_user_id: + member = guild.get_member(self.selected_user_id) if guild else None + user_name = f"@{member.display_name}" if member else f"<@{self.selected_user_id}>" + status = "Already Configured" if self.selected_user_id in user_ids else "Not Configured" + embed.add_field( + name="Selected Member", + value=f"**{user_name}** (`{self.selected_user_id}`) — *{status}*", + inline=False, + ) + + embed.set_footer(text="dgg-pm • Team Lead Administration") return embed async def _on_assign_clicked(self, interaction: discord.Interaction) -> None: @@ -297,7 +368,7 @@ async def _on_assign_clicked(self, interaction: discord.Interaction) -> None: if not AuthService.is_server_manager(interaction.user): await interaction.response.send_message( - "❌ Only Discord Server Managers can configure team lead roles.", ephemeral=True + "❌ Only Discord Server Managers can configure team leads.", ephemeral=True ) return @@ -323,7 +394,7 @@ async def _on_remove_clicked(self, interaction: discord.Interaction) -> None: if not AuthService.is_server_manager(interaction.user): await interaction.response.send_message( - "❌ Only Discord Server Managers can configure team lead roles.", ephemeral=True + "❌ Only Discord Server Managers can configure team leads.", ephemeral=True ) return @@ -342,6 +413,58 @@ async def _on_remove_clicked(self, interaction: discord.Interaction) -> None: embed = await self.build_embed(interaction.guild) await interaction.response.edit_message(embed=embed, view=self) + async def _on_assign_user_clicked(self, interaction: discord.Interaction) -> None: + if not interaction.guild: + await interaction.response.send_message("❌ Must be used inside a Discord server.", ephemeral=True) + return + + if not AuthService.is_server_manager(interaction.user): + await interaction.response.send_message( + "❌ Only Discord Server Managers can configure team leads.", ephemeral=True + ) + return + + selected_values = getattr(self.user_select, "values", []) or getattr(self.user_select, "_values", []) + if selected_values and not self.selected_user_id: + val = selected_values[0] + self.selected_user_id = int(val.id) if hasattr(val, "id") else int(val) + + if not self.selected_user_id: + await interaction.response.send_message("⚠️ Please select a member from the dropdown first.", ephemeral=True) + return + + if self.auth_service: + await self.auth_service.add_guild_lead_user(interaction.guild.id, self.selected_user_id) + + embed = await self.build_embed(interaction.guild) + await interaction.response.edit_message(embed=embed, view=self) + + async def _on_remove_user_clicked(self, interaction: discord.Interaction) -> None: + if not interaction.guild: + await interaction.response.send_message("❌ Must be used inside a Discord server.", ephemeral=True) + return + + if not AuthService.is_server_manager(interaction.user): + await interaction.response.send_message( + "❌ Only Discord Server Managers can configure team leads.", ephemeral=True + ) + return + + selected_values = getattr(self.user_select, "values", []) or getattr(self.user_select, "_values", []) + if selected_values and not self.selected_user_id: + val = selected_values[0] + self.selected_user_id = int(val.id) if hasattr(val, "id") else int(val) + + if not self.selected_user_id: + await interaction.response.send_message("⚠️ Please select a member from the dropdown first.", ephemeral=True) + return + + if self.auth_service: + await self.auth_service.remove_guild_lead_user(interaction.guild.id, self.selected_user_id) + + embed = await self.build_embed(interaction.guild) + await interaction.response.edit_message(embed=embed, view=self) + async def _on_back_clicked(self, interaction: discord.Interaction) -> None: if not interaction.guild: return @@ -373,6 +496,9 @@ async def _on_back_clicked(self, interaction: discord.Interaction) -> None: await interaction.response.edit_message(content=None, embed=embed, view=view) +LeadRolesAdminView = TeamLeadsAdminView + + class PmDashboardView(BaseView): """Interactive administration and project management dashboard view for /pm menu.""" @@ -430,7 +556,7 @@ def _rebuild_items(self) -> None: if self.is_server_admin: self.lead_roles_btn = discord.ui.Button( - label="Lead Roles", + label="Team Leads", style=discord.ButtonStyle.secondary, row=0, ) @@ -515,7 +641,7 @@ async def _on_lead_roles_clicked(self, interaction: discord.Interaction) -> None await interaction.response.send_message("❌ Must be run in a Discord server.", ephemeral=True) return - view = LeadRolesAdminView( + view = TeamLeadsAdminView( project_service=self.project_service, squad_service=self.squad_service, task_service=self.task_service, diff --git a/src/adapters/discord_bot/views/forum_helpers.py b/src/adapters/discord_bot/views/forum_helpers.py index 80050f4..cd83cfc 100644 --- a/src/adapters/discord_bot/views/forum_helpers.py +++ b/src/adapters/discord_bot/views/forum_helpers.py @@ -417,11 +417,20 @@ async def ensure_pinned_hub_post( sf = getattr(uow, "session_factory", None) or getattr(uow, "_session_factory", None) if not auth_service and squad_service and sf: try: - from src.adapters.db.postgres_repo import PostgresGuildLeadRoleRepository + from src.adapters.db.postgres_repo import ( + PostgresGuildLeadRoleRepository, + PostgresGuildLeadUserRepository, + ) from src.services.auth_service import AuthService - lead_repo = PostgresGuildLeadRoleRepository(sf) - auth_service = AuthService(project_service, squad_service, guild_lead_role_repo=lead_repo) + lead_role_repo = PostgresGuildLeadRoleRepository(sf) + lead_user_repo = PostgresGuildLeadUserRepository(sf) + auth_service = AuthService( + project_service, + squad_service, + guild_lead_role_repo=lead_role_repo, + guild_lead_user_repo=lead_user_repo, + ) except Exception as e: logger.debug("Could not auto-create auth_service for hub: %s", e) diff --git a/src/adapters/discord_bot/views/hub_menu.py b/src/adapters/discord_bot/views/hub_menu.py index 89f3b6c..7fdfea6 100644 --- a/src/adapters/discord_bot/views/hub_menu.py +++ b/src/adapters/discord_bot/views/hub_menu.py @@ -310,17 +310,27 @@ def __init__( if auth_service is not None: self.auth_service = auth_service else: - lead_repo = None + lead_role_repo = None + lead_user_repo = None uow = getattr(task_service, "uow", None) sf = getattr(uow, "session_factory", None) or getattr(uow, "_session_factory", None) if sf: try: - from src.adapters.db.postgres_repo import PostgresGuildLeadRoleRepository + from src.adapters.db.postgres_repo import ( + PostgresGuildLeadRoleRepository, + PostgresGuildLeadUserRepository, + ) - lead_repo = PostgresGuildLeadRoleRepository(sf) + lead_role_repo = PostgresGuildLeadRoleRepository(sf) + lead_user_repo = PostgresGuildLeadUserRepository(sf) except Exception as e: - logger.debug("Could not auto-create lead_role_repo for hub: %s", e) - self.auth_service = AuthService(project_service, squad_service, guild_lead_role_repo=lead_repo) + logger.debug("Could not auto-create lead repos for hub: %s", e) + self.auth_service = AuthService( + project_service, + squad_service, + guild_lead_role_repo=lead_role_repo, + guild_lead_user_repo=lead_user_repo, + ) async def _refresh_hub_message( self, diff --git a/src/main.py b/src/main.py index 134902a..6671349 100644 --- a/src/main.py +++ b/src/main.py @@ -15,6 +15,7 @@ from src.adapters.api.app import api_app # noqa: E402 from src.adapters.db.postgres_repo import ( # noqa: E402 PostgresGuildLeadRoleRepository, + PostgresGuildLeadUserRepository, PostgresOutboxRepo, PostgresProjectRepo, PostgresSquadRepo, @@ -56,6 +57,7 @@ async def run_app() -> None: outbox_repo = PostgresOutboxRepo(async_session_factory) user_pref_repo = PostgresUserPreferenceRepo(async_session_factory) guild_lead_role_repo = PostgresGuildLeadRoleRepository(async_session_factory) + guild_lead_user_repo = PostgresGuildLeadUserRepository(async_session_factory) uow = SqlAlchemyUnitOfWork(async_session_factory) project_service = ProjectService(project_repo) @@ -72,6 +74,7 @@ async def run_app() -> None: user_service=user_service, outbox_service=outbox_service, guild_lead_role_repo=guild_lead_role_repo, + guild_lead_user_repo=guild_lead_user_repo, ) notifier = DiscordNotifier( bot, diff --git a/src/ports/repositories.py b/src/ports/repositories.py index 95a4faf..e5d9471 100644 --- a/src/ports/repositories.py +++ b/src/ports/repositories.py @@ -313,3 +313,17 @@ async def remove_lead_role(self, guild_id: int, discord_role_id: int) -> bool: @abstractmethod async def list_lead_role_ids(self, guild_id: int) -> set[int]: """Lists all authorized Team Lead Discord role IDs for the guild.""" + + +class IGuildLeadUserRepository(ABC): + @abstractmethod + async def add_lead_user(self, guild_id: int, user_discord_id: int) -> None: + """Registers a Discord user ID as an authorized Team Lead for the guild.""" + + @abstractmethod + async def remove_lead_user(self, guild_id: int, user_discord_id: int) -> bool: + """Removes an authorized Team Lead user from the guild. Returns True if removed, False if not found.""" + + @abstractmethod + async def list_lead_user_ids(self, guild_id: int) -> set[int]: + """Lists all authorized Team Lead Discord user IDs for the guild.""" diff --git a/src/ports/unit_of_work.py b/src/ports/unit_of_work.py index ce7ea74..920e229 100644 --- a/src/ports/unit_of_work.py +++ b/src/ports/unit_of_work.py @@ -6,6 +6,7 @@ if TYPE_CHECKING: from src.ports.repositories import ( IGuildLeadRoleRepository, + IGuildLeadUserRepository, IOutboxRepo, IProjectRepo, ISquadRepo, @@ -23,6 +24,7 @@ class IUnitOfWork(ABC): outbox: IOutboxRepo user_prefs: IUserPreferenceRepo guild_lead_roles: IGuildLeadRoleRepository + guild_lead_users: IGuildLeadUserRepository @abstractmethod async def __aenter__(self) -> IUnitOfWork: diff --git a/src/services/auth_service.py b/src/services/auth_service.py index 37a3da5..6e77fad 100644 --- a/src/services/auth_service.py +++ b/src/services/auth_service.py @@ -13,7 +13,7 @@ from src.domain.models import Project, Task if TYPE_CHECKING: - from src.ports.repositories import IGuildLeadRoleRepository + from src.ports.repositories import IGuildLeadRoleRepository, IGuildLeadUserRepository from src.services.project_service import ProjectService from src.services.squad_service import SquadService @@ -28,10 +28,12 @@ def __init__( project_service: ProjectService, squad_service: SquadService | None = None, guild_lead_role_repo: IGuildLeadRoleRepository | None = None, + guild_lead_user_repo: IGuildLeadUserRepository | None = None, ): self.project_service = project_service self.squad_service = squad_service self.guild_lead_role_repo = guild_lead_role_repo + self.guild_lead_user_repo = guild_lead_user_repo async def add_guild_lead_role(self, guild_id: int, discord_role_id: int) -> None: """Registers a Discord role as an authorized Team Lead role for the guild.""" @@ -50,6 +52,23 @@ async def list_guild_lead_roles(self, guild_id: int) -> set[int]: return await self.guild_lead_role_repo.list_lead_role_ids(guild_id) return set() + async def add_guild_lead_user(self, guild_id: int, user_discord_id: int) -> None: + """Registers a Discord user as an authorized Team Lead for the guild.""" + if self.guild_lead_user_repo: + await self.guild_lead_user_repo.add_lead_user(guild_id, user_discord_id) + + async def remove_guild_lead_user(self, guild_id: int, user_discord_id: int) -> bool: + """Removes a Discord user from authorized Team Leads for the guild.""" + if self.guild_lead_user_repo: + return await self.guild_lead_user_repo.remove_lead_user(guild_id, user_discord_id) + return False + + async def list_guild_lead_users(self, guild_id: int) -> set[int]: + """Returns all authorized Team Lead user IDs for the guild.""" + if self.guild_lead_user_repo: + return await self.guild_lead_user_repo.list_lead_user_ids(guild_id) + return set() + async def can_manage_projects( self, user: discord.Member | discord.User, @@ -58,8 +77,9 @@ async def can_manage_projects( ) -> bool: """Checks if user has authority to create and manage projects and squads: 1. Discord Server Manager (manage_guild or administrator). - 2. Holds any authorized Team Lead role configured for the guild. - 3. Active Squad Lead in the guild. + 2. Authorized Individual Team Lead members (guild_lead_users). + 3. Configured Team Lead roles (guild_lead_roles). + 4. Active Squad Leads in the guild. """ if self.is_server_manager(user): return True @@ -68,6 +88,13 @@ async def can_manage_projects( if not target_guild_id: return False + # 2. Check configured individual Team Lead members + user_id = getattr(user, "id", None) + if self.guild_lead_user_repo and user_id: + lead_user_ids = await self.guild_lead_user_repo.list_lead_user_ids(target_guild_id) + if user_id in lead_user_ids: + return True + # If user is a discord.User or missing roles, attempt to resolve member from guild member = user if not hasattr(member, "roles") or not getattr(member, "roles", None): @@ -85,7 +112,7 @@ async def can_manage_projects( except Exception: pass - # 2. Check configured Team Lead roles + # 3. Check configured Team Lead roles if self.guild_lead_role_repo: lead_role_ids = await self.guild_lead_role_repo.list_lead_role_ids(target_guild_id) if lead_role_ids: @@ -93,7 +120,7 @@ async def can_manage_projects( if any(rid in user_role_ids for rid in lead_role_ids): return True - # 3. Check if active Squad Lead in this guild + # 4. Check if active Squad Lead in this guild if self.squad_service and getattr(member, "id", None): squads = await self.squad_service.list_squads(target_guild_id) for s in squads: @@ -112,7 +139,7 @@ async def require_project_management( if not await self.can_manage_projects(user, guild_id=guild_id, guild=guild): raise PermissionDeniedError( "You do not have permission to manage projects or squads. " - "You must be a Discord Server Manager or hold an authorized Team Lead role." + "You must be a Discord Server Manager, an authorized Team Lead, or hold an authorized Team Lead role." ) @staticmethod diff --git a/tests/conftest.py b/tests/conftest.py index e3d17b1..aeb11ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -67,7 +67,10 @@ async def db_session(async_engine: AsyncEngine) -> AsyncGenerator[AsyncSession]: @pytest_asyncio.fixture(scope="function") async def repos(db_session: AsyncSession): - from src.adapters.db.postgres_repo import PostgresGuildLeadRoleRepository + from src.adapters.db.postgres_repo import ( + PostgresGuildLeadRoleRepository, + PostgresGuildLeadUserRepository, + ) task_repo = PostgresTaskRepo(db_session) project_repo = PostgresProjectRepo(db_session) @@ -75,6 +78,7 @@ async def repos(db_session: AsyncSession): outbox_repo = PostgresOutboxRepo(db_session) user_repo = PostgresUserPreferenceRepo(db_session) guild_lead_role_repo = PostgresGuildLeadRoleRepository(db_session) + guild_lead_user_repo = PostgresGuildLeadUserRepository(db_session) return { "task": task_repo, "project": project_repo, @@ -82,6 +86,7 @@ async def repos(db_session: AsyncSession): "outbox": outbox_repo, "user": user_repo, "guild_lead_role": guild_lead_role_repo, + "guild_lead_user": guild_lead_user_repo, } diff --git a/tests/test_authorization.py b/tests/test_authorization.py index 6b0959b..5568c05 100644 --- a/tests/test_authorization.py +++ b/tests/test_authorization.py @@ -599,6 +599,41 @@ async def test_on_member_remove_auto_prunes_squad_lead(services): assert await squad_srv.is_squad_lead(squad2.id, user_id) is False +@pytest.mark.asyncio +async def test_on_member_remove_auto_prunes_individual_lead(services, repos): + """When an authorized individual lead leaves the server, their lead record is pruned.""" + proj_srv = services["project"] + squad_srv = services["squad"] + task_srv = services["task"] + lead_user_repo = repos["guild_lead_user"] + guild_id = 9990014 + user_id = 9004 + + auth_srv = AuthService( + proj_srv, + squad_srv, + guild_lead_user_repo=lead_user_repo, + ) + bot = DggPmBot( + task_service=task_srv, + project_service=proj_srv, + squad_service=squad_srv, + auth_service=auth_srv, + ) + + await auth_srv.add_guild_lead_user(guild_id, user_id) + assert await auth_srv.list_guild_lead_users(guild_id) == {user_id} + + mock_guild = MagicMock() + mock_guild.id = guild_id + leaving_member = _make_mock_member(user_id) + leaving_member.guild = mock_guild + + await bot.on_member_remove(leaving_member) + + assert await auth_srv.list_guild_lead_users(guild_id) == set() + + @pytest.mark.asyncio async def test_project_lead_authorization(services): """Verify Project Leads have elevated permissions to create, assign, and mutate tasks in their projects.""" @@ -1422,6 +1457,46 @@ async def test_auth_service_can_manage_projects_lifecycle(services, repos): assert await auth_srv.can_manage_projects(team_lead_member, guild_id) is False +@pytest.mark.asyncio +async def test_auth_service_individual_lead_user_lifecycle(services, repos): + """Verify AuthService.can_manage_projects allows designated individual Team Lead members.""" + proj_srv = services["project"] + squad_srv = services["squad"] + lead_role_repo = repos["guild_lead_role"] + lead_user_repo = repos["guild_lead_user"] + auth_srv = AuthService( + proj_srv, + squad_srv, + guild_lead_role_repo=lead_role_repo, + guild_lead_user_repo=lead_user_repo, + ) + + guild_id = 888777666 + individual_lead_member = _make_mock_member(5501, role_ids=[123], manage_guild=False) + regular_member = _make_mock_member(5502, role_ids=[123], manage_guild=False) + + # 1. Initially, individual member is not authorized + assert await auth_srv.can_manage_projects(individual_lead_member, guild_id) is False + with pytest.raises(PermissionDeniedError, match="do not have permission"): + await auth_srv.require_project_management(individual_lead_member, guild_id) + + # 2. Add individual lead user + await auth_srv.add_guild_lead_user(guild_id, individual_lead_member.id) + assert await auth_srv.list_guild_lead_users(guild_id) == {individual_lead_member.id} + + # Now individual lead is authorized! + assert await auth_srv.can_manage_projects(individual_lead_member, guild_id) is True + await auth_srv.require_project_management(individual_lead_member, guild_id) + + # Regular member is still denied + assert await auth_srv.can_manage_projects(regular_member, guild_id) is False + + # 3. Remove individual lead user + assert await auth_srv.remove_guild_lead_user(guild_id, individual_lead_member.id) is True + assert await auth_srv.list_guild_lead_users(guild_id) == set() + assert await auth_srv.can_manage_projects(individual_lead_member, guild_id) is False + + @pytest.mark.asyncio async def test_pm_hub_projects_tab_authorizes_lead_role_to_create_projects(services, repos): """Verify that clicking Projects Hub on PmHubView shows 'New Project' for Team Lead roles.""" @@ -1506,8 +1581,8 @@ async def test_can_manage_projects_resolves_member_when_user_is_discord_user(ser @pytest.mark.asyncio -async def test_pm_hub_view_auto_wires_lead_role_repo_and_has_create_project_button(services, repos): - """Verify PmHubView auto-wires guild_lead_role_repo if auth_service is omitted, and has Create Project button.""" +async def test_pm_hub_view_auto_wires_lead_role_repo_and_has_create_project_button(services, repos, db_session): + """Verify PmHubView auto-wires lead repos if auth_service is omitted, and has Create Project button.""" from src.adapters.discord_bot.views.hub_menu import PmHubView from src.adapters.discord_bot.views.project_menu import ProjectChannelSelectView @@ -1515,27 +1590,33 @@ async def test_pm_hub_view_auto_wires_lead_role_repo_and_has_create_project_butt squad_srv = services["squad"] task_srv = services["task"] lead_role_repo = repos["guild_lead_role"] + lead_user_repo = repos["guild_lead_user"] guild_id = 777111 lead_role_id = 888222 + lead_user_id = 6003 await lead_role_repo.add_lead_role(guild_id, lead_role_id) + await lead_user_repo.add_lead_user(guild_id, lead_user_id) + await db_session.commit() - lead_member = _make_mock_member(6001, role_ids=[lead_role_id], manage_guild=False) + lead_role_member = _make_mock_member(6001, role_ids=[lead_role_id], manage_guild=False) reg_member = _make_mock_member(6002, role_ids=[], manage_guild=False) + lead_user_member = _make_mock_member(lead_user_id, role_ids=[], manage_guild=False) # Instantiate PmHubView WITHOUT auth_service hub_view = PmHubView(proj_srv, squad_srv, task_srv) assert hub_view.auth_service.guild_lead_role_repo is not None + assert hub_view.auth_service.guild_lead_user_repo is not None # Verify Create Project button exists on row 1 assert hasattr(hub_view, "create_project_btn") assert hub_view.create_project_btn.label == "Create Project" - # 1. Lead member clicks Create Project button directly on PmHubView + # 1. Lead role member clicks Create Project button directly on PmHubView inter = MagicMock(spec=discord.Interaction) inter.guild = MagicMock(id=guild_id) - inter.guild.get_member.return_value = lead_member - inter.user = lead_member + inter.guild.get_member.return_value = lead_role_member + inter.user = lead_role_member inter.response = MagicMock() inter.response.send_message = AsyncMock() @@ -1544,7 +1625,17 @@ async def test_pm_hub_view_auto_wires_lead_role_repo_and_has_create_project_butt kwargs = inter.response.send_message.call_args.kwargs assert isinstance(kwargs["view"], ProjectChannelSelectView) - # 2. Regular member clicks Create Project button -> permission error + # 2. Individual lead user clicks Create Project button -> allowed + inter.response.send_message.reset_mock() + inter.user = lead_user_member + inter.guild.get_member.return_value = lead_user_member + + await hub_view.create_project_btn.callback(inter) + inter.response.send_message.assert_awaited_once() + kwargs = inter.response.send_message.call_args.kwargs + assert isinstance(kwargs["view"], ProjectChannelSelectView) + + # 3. Regular member clicks Create Project button -> permission error inter.response.send_message.reset_mock() inter.user = reg_member inter.guild.get_member.return_value = reg_member diff --git a/tests/test_cogs.py b/tests/test_cogs.py index 5e057db..361fd2b 100644 --- a/tests/test_cogs.py +++ b/tests/test_cogs.py @@ -2032,6 +2032,108 @@ async def test_admin_lead_role_execution(services, repos): assert "Please specify a Discord role" in interaction.followup.send.call_args[0][0] +@pytest.mark.asyncio +async def test_admin_lead_command_metadata(): + """Verify that PmCog defines admin_group with unified 'lead' command and 'lead-role' alias.""" + from src.adapters.discord_bot.cogs.pm_cog import PmCog + + assert hasattr(PmCog, "admin_group") + cmd = next((c for c in PmCog.admin_group.commands if c.name == "lead"), None) + assert cmd is not None + params = [p.name for p in cmd.parameters] + assert "action" in params + assert "role" in params + assert "user" in params + + # Legacy alias still exists + legacy_cmd = next((c for c in PmCog.admin_group.commands if c.name == "lead-role"), None) + assert legacy_cmd is not None + + +@pytest.mark.asyncio +async def test_admin_lead_execution(services, repos): + """Verify /pm admin lead can add, list, and remove authorized Team Lead roles and users.""" + from src.adapters.discord_bot.cogs.pm_cog import PmCog + from src.services.auth_service import AuthService + + proj_srv = services["project"] + squad_srv = services["squad"] + lead_role_repo = repos["guild_lead_role"] + lead_user_repo = repos["guild_lead_user"] + auth_srv = AuthService( + proj_srv, + squad_srv, + guild_lead_role_repo=lead_role_repo, + guild_lead_user_repo=lead_user_repo, + ) + + mock_bot = MagicMock() + cog = PmCog( + bot=mock_bot, + project_service=proj_srv, + squad_service=squad_srv, + task_service=services["task"], + auth_service=auth_srv, + ) + + guild_id = 8877665544 + interaction = MagicMock(spec=discord.Interaction) + interaction.guild = MagicMock(id=guild_id) + interaction.guild.get_role.return_value = None + interaction.guild.get_member.return_value = None + interaction.response = MagicMock() + interaction.response.defer = AsyncMock() + interaction.followup = MagicMock() + interaction.followup.send = AsyncMock() + + mock_role = MagicMock(spec=discord.Role, id=223344, name="Lead Role") + mock_user = MagicMock(spec=discord.Member, id=998877, name="LeadUser", bot=False) + mock_bot_user = MagicMock(spec=discord.Member, id=998899, name="BotUser", bot=True) + + # 1. Add validation error: neither role nor user provided + await cog.admin_lead.callback(cog, interaction, action="add", role=None, user=None) + interaction.followup.send.assert_awaited() + assert "specify a role, a member, or both" in interaction.followup.send.call_args[0][0] + + # 2. Reject bot user + interaction.followup.send.reset_mock() + await cog.admin_lead.callback(cog, interaction, action="add", role=None, user=mock_bot_user) + assert "Bots cannot be authorized" in interaction.followup.send.call_args[0][0] + + # 3. Add individual lead user + interaction.followup.send.reset_mock() + await cog.admin_lead.callback(cog, interaction, action="add", role=None, user=mock_user) + assert "Added Team Lead" in interaction.followup.send.call_args[0][0] + assert await auth_srv.list_guild_lead_users(guild_id) == {998877} + + # 4. Add role as well + interaction.followup.send.reset_mock() + await cog.admin_lead.callback(cog, interaction, action="add", role=mock_role, user=None) + assert "Added Team Lead" in interaction.followup.send.call_args[0][0] + assert await auth_srv.list_guild_lead_roles(guild_id) == {223344} + + # 5. List leads (both role and user should appear) + interaction.followup.send.reset_mock() + await cog.admin_lead.callback(cog, interaction, action="list") + embed = interaction.followup.send.call_args[1].get("embed") + assert embed is not None + field_names = [f.name for f in embed.fields] + assert any("Roles" in name for name in field_names) + assert any("Members" in name for name in field_names) + + # 6. Remove user + interaction.followup.send.reset_mock() + await cog.admin_lead.callback(cog, interaction, action="remove", role=None, user=mock_user) + assert "Removed Team Lead" in interaction.followup.send.call_args[0][0] + assert await auth_srv.list_guild_lead_users(guild_id) == set() + + # 7. Remove role + interaction.followup.send.reset_mock() + await cog.admin_lead.callback(cog, interaction, action="remove", role=mock_role, user=None) + assert "Removed Team Lead" in interaction.followup.send.call_args[0][0] + assert await auth_srv.list_guild_lead_roles(guild_id) == set() + + @pytest.mark.asyncio async def test_project_and_squad_create_by_team_lead_role(services, repos): """Verify that users holding authorized Team Lead roles can create projects and squads without manage_guild.""" diff --git a/tests/test_menus.py b/tests/test_menus.py index bdce608..e713532 100644 --- a/tests/test_menus.py +++ b/tests/test_menus.py @@ -1543,7 +1543,7 @@ async def test_lead_roles_admin_menu_lifecycle(services, repos): assert admin_dash.is_server_manager is True assert hasattr(admin_dash, "lead_roles_btn") assert admin_dash.lead_roles_btn is not None - assert admin_dash.lead_roles_btn.label == "Lead Roles" + assert admin_dash.lead_roles_btn.label in ("Team Leads", "Lead Roles") # Regular user dashboard view hides Lead Roles button reg_dash = PmDashboardView( @@ -1569,7 +1569,7 @@ async def test_lead_roles_admin_menu_lifecycle(services, repos): lead_view = inter.response.edit_message.call_args.kwargs["view"] assert isinstance(lead_view, LeadRolesAdminView) lead_embed = inter.response.edit_message.call_args.kwargs["embed"] - assert "Authorized Team Lead Roles" in lead_embed.title + assert "Authorized Team Lead" in lead_embed.title # 3. Assign role via LeadRolesAdminView mock_role = MagicMock(spec=discord.Role) @@ -1612,6 +1612,96 @@ async def test_lead_roles_admin_menu_lifecycle(services, repos): assert isinstance(back_view, PmDashboardView) +@pytest.mark.asyncio +async def test_team_leads_admin_view_lifecycle(services, repos): + """Verify TeamLeadsAdminView allows managing both roles and individual users via RoleSelect and UserSelect.""" + from src.adapters.discord_bot.views.admin_menu import ( + PmDashboardView, + TeamLeadsAdminView, + ) + from src.services.auth_service import AuthService + + proj_srv = services["project"] + squad_srv = services["squad"] + task_srv = services["task"] + user_srv = services["user"] + lead_role_repo = repos["guild_lead_role"] + lead_user_repo = repos["guild_lead_user"] + auth_srv = AuthService( + proj_srv, + squad_srv, + guild_lead_role_repo=lead_role_repo, + guild_lead_user_repo=lead_user_repo, + ) + + guild_id = 987654321 + mock_guild = MagicMock(spec=discord.Guild) + mock_guild.id = guild_id + mock_guild.name = "Test Guild" + + admin_user = MagicMock(spec=discord.Member) + admin_user.id = 9991 + admin_user.guild_permissions = discord.Permissions(manage_guild=True) + + # 1. PmDashboardView shows Team Leads button + dash = PmDashboardView( + project_service=proj_srv, + squad_service=squad_srv, + task_service=task_srv, + user_service=user_srv, + auth_service=auth_srv, + user=admin_user, + ) + assert dash.lead_roles_btn is not None + assert dash.lead_roles_btn.label == "Team Leads" + + # 2. TeamLeadsAdminView has both RoleSelect and UserSelect + team_view = TeamLeadsAdminView( + project_service=proj_srv, + squad_service=squad_srv, + task_service=task_srv, + user_service=user_srv, + auth_service=auth_srv, + ) + assert hasattr(team_view, "role_select") + assert hasattr(team_view, "user_select") + assert hasattr(team_view, "assign_user_btn") + assert hasattr(team_view, "remove_user_btn") + + # 3. Assign an individual user + mock_target_user = MagicMock(spec=discord.Member) + mock_target_user.id = 770011 + mock_target_user.display_name = "Alice" + mock_guild.get_member.return_value = mock_target_user + + team_view.user_select._values = [str(mock_target_user.id)] + assign_inter = MagicMock(spec=discord.Interaction) + assign_inter.guild = mock_guild + assign_inter.user = admin_user + assign_inter.response = MagicMock() + assign_inter.response.edit_message = AsyncMock() + + await team_view._on_assign_user_clicked(assign_inter) + assert await auth_srv.list_guild_lead_users(guild_id) == {mock_target_user.id} + + # 4. Verify embed renders both roles and users + embed = await team_view.build_embed(mock_guild) + assert "Authorized Team Leads" in embed.title + field_names = [f.name for f in embed.fields] + assert any("Roles" in name for name in field_names) + assert any("Members" in name for name in field_names) + + # 5. Remove individual user + remove_inter = MagicMock(spec=discord.Interaction) + remove_inter.guild = mock_guild + remove_inter.user = admin_user + remove_inter.response = MagicMock() + remove_inter.response.edit_message = AsyncMock() + + await team_view._on_remove_user_clicked(remove_inter) + assert await auth_srv.list_guild_lead_users(guild_id) == set() + + def test_build_hub_welcome_embed_squad_roles(): from src.domain.models import Project, Squad diff --git a/tests/test_repos.py b/tests/test_repos.py index 075fec4..6407e4a 100644 --- a/tests/test_repos.py +++ b/tests/test_repos.py @@ -189,3 +189,36 @@ async def test_guild_lead_role_repo_lifecycle(db_session): # Remove non-existent role assert await repo.remove_lead_role(guild_id, 999999) is False assert await repo.list_lead_role_ids(guild_id) == {333444} + + +@pytest.mark.asyncio +async def test_guild_lead_user_repo_lifecycle(db_session): + """Verify IGuildLeadUserRepository can add, list, and remove authorized lead user IDs.""" + from src.adapters.db.postgres_repo import PostgresGuildLeadUserRepository + + repo = PostgresGuildLeadUserRepository(db_session) + guild_id = 9876543210 + + # Initially empty + assert await repo.list_lead_user_ids(guild_id) == set() + + # Add users + await repo.add_lead_user(guild_id, 100001) + await repo.add_lead_user(guild_id, 100002) + + # Adding duplicate is idempotent + await repo.add_lead_user(guild_id, 100001) + + lead_users = await repo.list_lead_user_ids(guild_id) + assert lead_users == {100001, 100002} + + # Different guild is isolated + assert await repo.list_lead_user_ids(555555) == set() + + # Remove existing user + assert await repo.remove_lead_user(guild_id, 100001) is True + assert await repo.list_lead_user_ids(guild_id) == {100002} + + # Remove non-existent user + assert await repo.remove_lead_user(guild_id, 999999) is False + assert await repo.list_lead_user_ids(guild_id) == {100002}