aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorGravatar ChrisJL <[email protected]>2021-07-06 09:02:15 +0100
committerGravatar GitHub <[email protected]>2021-07-06 09:02:15 +0100
commit7f0e8a8bfddffcef077afd668acedb61e22c02c1 (patch)
tree8ee4991aa16dbcdaf4d2d42ac503a0be4ba6e807
parentAdds Documentation For Running A Single Test (#1669) (diff)
parentMerge branch 'main' into feat/code-jam-channels-automation (diff)
Merge pull request #1668 from python-discord/feat/code-jam-channels-automation
chore: Add the codejam create command
-rw-r--r--bot/constants.py6
-rw-r--r--bot/exts/utils/jams.py171
-rw-r--r--config-default.yml5
-rw-r--r--tests/bot/exts/utils/test_jams.py137
-rw-r--r--tests/helpers.py22
5 files changed, 202 insertions, 139 deletions
diff --git a/bot/constants.py b/bot/constants.py
index 3d960f22b..f1c9c2c32 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -400,6 +400,8 @@ class Categories(metaclass=YAMLGetter):
modmail: int
voice: int
+ # 2021 Summer Code Jam
+ summer_code_jam: int
class Channels(metaclass=YAMLGetter):
section = "guild"
@@ -439,6 +441,7 @@ class Channels(metaclass=YAMLGetter):
discord_py: int
esoteric: int
voice_gate: int
+ code_jam_planning: int
admins: int
admin_spam: int
@@ -497,8 +500,10 @@ class Roles(metaclass=YAMLGetter):
admins: int
core_developers: int
+ code_jam_event_team: int
devops: int
domain_leads: int
+ events_lead: int
helpers: int
moderators: int
mod_team: int
@@ -506,7 +511,6 @@ class Roles(metaclass=YAMLGetter):
project_leads: int
jammers: int
- team_leaders: int
class Guild(metaclass=YAMLGetter):
diff --git a/bot/exts/utils/jams.py b/bot/exts/utils/jams.py
index 98fbcb303..87ae847f6 100644
--- a/bot/exts/utils/jams.py
+++ b/bot/exts/utils/jams.py
@@ -1,17 +1,19 @@
+import csv
import logging
import typing as t
+from collections import defaultdict
-from discord import CategoryChannel, Guild, Member, PermissionOverwrite, Role
+import discord
from discord.ext import commands
-from more_itertools import unique_everseen
from bot.bot import Bot
-from bot.constants import Roles
+from bot.constants import Categories, Channels, Emojis, Roles
log = logging.getLogger(__name__)
MAX_CHANNELS = 50
CATEGORY_NAME = "Code Jam"
+TEAM_LEADERS_COLOUR = 0x11806a
class CodeJams(commands.Cog):
@@ -20,124 +22,153 @@ class CodeJams(commands.Cog):
def __init__(self, bot: Bot):
self.bot = bot
- @commands.command()
+ @commands.group()
@commands.has_any_role(Roles.admins)
- async def createteam(self, ctx: commands.Context, team_name: str, members: commands.Greedy[Member]) -> None:
+ async def codejam(self, ctx: commands.Context) -> None:
+ """A Group of commands for managing Code Jams."""
+ if ctx.invoked_subcommand is None:
+ await ctx.send_help(ctx.command)
+
+ @codejam.command()
+ async def create(self, ctx: commands.Context, csv_file: t.Optional[str]) -> None:
"""
- Create team channels (voice and text) in the Code Jams category, assign roles, and add overwrites for the team.
+ Create code-jam teams from a CSV file or a link to one, specifying the team names, leaders and members.
+
+ The CSV file must have 3 columns: 'Team Name', 'Team Member Discord ID', and 'Team Leader'.
- The first user passed will always be the team leader.
+ This will create the text channels for the teams, and give the team leaders their roles.
"""
- # Ignore duplicate members
- members = list(unique_everseen(members))
-
- # We had a little issue during Code Jam 4 here, the greedy converter did it's job
- # and ignored anything which wasn't a valid argument which left us with teams of
- # two members or at some times even 1 member. This fixes that by checking that there
- # are always 3 members in the members list.
- if len(members) < 3:
- await ctx.send(
- ":no_entry_sign: One of your arguments was invalid\n"
- f"There must be a minimum of 3 valid members in your team. Found: {len(members)}"
- " members"
- )
- return
+ async with ctx.typing():
+ if csv_file:
+ async with self.bot.http_session.get(csv_file) as response:
+ if response.status != 200:
+ await ctx.send(f"Got a bad response from the URL: {response.status}")
+ return
- team_channel = await self.create_channels(ctx.guild, team_name, members)
- await self.add_roles(ctx.guild, members)
+ csv_file = await response.text()
- await ctx.send(
- f":ok_hand: Team created: {team_channel}\n"
- f"**Team Leader:** {members[0].mention}\n"
- f"**Team Members:** {' '.join(member.mention for member in members[1:])}"
- )
+ elif ctx.message.attachments:
+ csv_file = (await ctx.message.attachments[0].read()).decode("utf8")
+ else:
+ raise commands.BadArgument("You must include either a CSV file or a link to one.")
+
+ teams = defaultdict(list)
+ reader = csv.DictReader(csv_file.splitlines())
+
+ for row in reader:
+ member = ctx.guild.get_member(int(row["Team Member Discord ID"]))
+
+ if member is None:
+ log.trace(f"Got an invalid member ID: {row['Team Member Discord ID']}")
+ continue
+
+ teams[row["Team Name"]].append((member, row["Team Leader"].upper() == "Y"))
+
+ team_leaders = await ctx.guild.create_role(name="Code Jam Team Leaders", colour=TEAM_LEADERS_COLOUR)
+
+ for team_name, members in teams.items():
+ await self.create_team_channel(ctx.guild, team_name, members, team_leaders)
- async def get_category(self, guild: Guild) -> CategoryChannel:
+ await self.create_team_leader_channel(ctx.guild, team_leaders)
+ await ctx.send(f"{Emojis.check_mark} Created Code Jam with {len(teams)} teams.")
+
+ async def get_category(self, guild: discord.Guild) -> discord.CategoryChannel:
"""
Return a code jam category.
If all categories are full or none exist, create a new category.
"""
for category in guild.categories:
- # Need 2 available spaces: one for the text channel and one for voice.
- if category.name == CATEGORY_NAME and MAX_CHANNELS - len(category.channels) >= 2:
+ if category.name == CATEGORY_NAME and len(category.channels) < MAX_CHANNELS:
return category
return await self.create_category(guild)
- @staticmethod
- async def create_category(guild: Guild) -> CategoryChannel:
+ async def create_category(self, guild: discord.Guild) -> discord.CategoryChannel:
"""Create a new code jam category and return it."""
log.info("Creating a new code jam category.")
category_overwrites = {
- guild.default_role: PermissionOverwrite(read_messages=False),
- guild.me: PermissionOverwrite(read_messages=True)
+ guild.default_role: discord.PermissionOverwrite(read_messages=False),
+ guild.me: discord.PermissionOverwrite(read_messages=True)
}
- return await guild.create_category_channel(
+ category = await guild.create_category_channel(
CATEGORY_NAME,
overwrites=category_overwrites,
reason="It's code jam time!"
)
+ await self.send_status_update(
+ guild, f"Created a new category with the ID {category.id} for this Code Jam's team channels."
+ )
+
+ return category
+
@staticmethod
- def get_overwrites(members: t.List[Member], guild: Guild) -> t.Dict[t.Union[Member, Role], PermissionOverwrite]:
+ def get_overwrites(
+ members: list[tuple[discord.Member, bool]],
+ guild: discord.Guild,
+ ) -> dict[t.Union[discord.Member, discord.Role], discord.PermissionOverwrite]:
"""Get code jam team channels permission overwrites."""
- # First member is always the team leader
team_channel_overwrites = {
- members[0]: PermissionOverwrite(
- manage_messages=True,
- read_messages=True,
- manage_webhooks=True,
- connect=True
- ),
- guild.default_role: PermissionOverwrite(read_messages=False, connect=False),
+ guild.default_role: discord.PermissionOverwrite(read_messages=False),
+ guild.get_role(Roles.code_jam_event_team): discord.PermissionOverwrite(read_messages=True)
}
- # Rest of members should just have read_messages
- for member in members[1:]:
- team_channel_overwrites[member] = PermissionOverwrite(
- read_messages=True,
- connect=True
+ for member, _ in members:
+ team_channel_overwrites[member] = discord.PermissionOverwrite(
+ read_messages=True
)
return team_channel_overwrites
- async def create_channels(self, guild: Guild, team_name: str, members: t.List[Member]) -> str:
- """Create team text and voice channels. Return the mention for the text channel."""
+ async def create_team_channel(
+ self,
+ guild: discord.Guild,
+ team_name: str,
+ members: list[tuple[discord.Member, bool]],
+ team_leaders: discord.Role
+ ) -> None:
+ """Create the team's text channel."""
+ await self.add_team_leader_roles(members, team_leaders)
+
# Get permission overwrites and category
team_channel_overwrites = self.get_overwrites(members, guild)
code_jam_category = await self.get_category(guild)
# Create a text channel for the team
- team_channel = await guild.create_text_channel(
+ await code_jam_category.create_text_channel(
team_name,
overwrites=team_channel_overwrites,
- category=code_jam_category
)
- # Create a voice channel for the team
- team_voice_name = " ".join(team_name.split("-")).title()
+ async def create_team_leader_channel(self, guild: discord.Guild, team_leaders: discord.Role) -> None:
+ """Create the Team Leader Chat channel for the Code Jam team leaders."""
+ category: discord.CategoryChannel = guild.get_channel(Categories.summer_code_jam)
- await guild.create_voice_channel(
- team_voice_name,
- overwrites=team_channel_overwrites,
- category=code_jam_category
+ team_leaders_chat = await category.create_text_channel(
+ name="team-leaders-chat",
+ overwrites={
+ guild.default_role: discord.PermissionOverwrite(read_messages=False),
+ team_leaders: discord.PermissionOverwrite(read_messages=True)
+ }
)
- return team_channel.mention
+ await self.send_status_update(guild, f"Created {team_leaders_chat.mention} in the {category} category.")
+
+ async def send_status_update(self, guild: discord.Guild, message: str) -> None:
+ """Inform the events lead with a status update when the command is ran."""
+ channel: discord.TextChannel = guild.get_channel(Channels.code_jam_planning)
+
+ await channel.send(f"<@&{Roles.events_lead}>\n\n{message}")
@staticmethod
- async def add_roles(guild: Guild, members: t.List[Member]) -> None:
- """Assign team leader and jammer roles."""
- # Assign team leader role
- await members[0].add_roles(guild.get_role(Roles.team_leaders))
-
- # Assign rest of roles
- jammer_role = guild.get_role(Roles.jammers)
- for member in members:
- await member.add_roles(jammer_role)
+ async def add_team_leader_roles(members: list[tuple[discord.Member, bool]], team_leaders: discord.Role) -> None:
+ """Assign team leader role, the jammer role and their team role."""
+ for member, is_leader in members:
+ if is_leader:
+ await member.add_roles(team_leaders)
def setup(bot: Bot) -> None:
diff --git a/config-default.yml b/config-default.yml
index f4fdc7606..7bc176135 100644
--- a/config-default.yml
+++ b/config-default.yml
@@ -142,6 +142,7 @@ guild:
moderators: &MODS_CATEGORY 749736277464842262
modmail: &MODMAIL 714494672835444826
voice: 356013253765234688
+ summer_code_jam: 861692638540857384
channels:
# Public announcement and news channels
@@ -188,6 +189,7 @@ guild:
bot_commands: &BOT_CMD 267659945086812160
esoteric: 470884583684964352
voice_gate: 764802555427029012
+ code_jam_planning: 490217981872177157
# Staff
admins: &ADMINS 365960823622991872
@@ -264,8 +266,10 @@ guild:
# Staff
admins: &ADMINS_ROLE 267628507062992896
core_developers: 587606783669829632
+ code_jam_event_team: 787816728474288181
devops: 409416496733880320
domain_leads: 807415650778742785
+ events_lead: 778361735739998228
helpers: &HELPERS_ROLE 267630620367257601
moderators: &MODS_ROLE 831776746206265384
mod_team: &MOD_TEAM_ROLE 267629731250176001
@@ -274,7 +278,6 @@ guild:
# Code Jam
jammers: 737249140966162473
- team_leaders: 737250302834638889
# Streaming
video: 764245844798079016
diff --git a/tests/bot/exts/utils/test_jams.py b/tests/bot/exts/utils/test_jams.py
index 85d6a1173..368a15476 100644
--- a/tests/bot/exts/utils/test_jams.py
+++ b/tests/bot/exts/utils/test_jams.py
@@ -2,10 +2,24 @@ import unittest
from unittest.mock import AsyncMock, MagicMock, create_autospec
from discord import CategoryChannel
+from discord.ext.commands import BadArgument
from bot.constants import Roles
from bot.exts.utils import jams
-from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole, MockTextChannel
+from tests.helpers import (
+ MockAttachment, MockBot, MockCategoryChannel, MockContext,
+ MockGuild, MockMember, MockRole, MockTextChannel
+)
+
+TEST_CSV = b"""\
+Team Name,Team Member Discord ID,Team Leader
+Annoyed Alligators,12345,Y
+Annoyed Alligators,54321,N
+Oscillating Otters,12358,Y
+Oscillating Otters,74832,N
+Oscillating Otters,19903,N
+Annoyed Alligators,11111,N
+"""
def get_mock_category(channel_count: int, name: str) -> CategoryChannel:
@@ -17,8 +31,8 @@ def get_mock_category(channel_count: int, name: str) -> CategoryChannel:
return category
-class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase):
- """Tests for `createteam` command."""
+class JamCodejamCreateTests(unittest.IsolatedAsyncioTestCase):
+ """Tests for `codejam create` command."""
def setUp(self):
self.bot = MockBot()
@@ -28,60 +42,64 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase):
self.ctx = MockContext(bot=self.bot, author=self.command_user, guild=self.guild)
self.cog = jams.CodeJams(self.bot)
- async def test_too_small_amount_of_team_members_passed(self):
- """Should `ctx.send` and exit early when too small amount of members."""
- for case in (1, 2):
- with self.subTest(amount_of_members=case):
- self.cog.create_channels = AsyncMock()
- self.cog.add_roles = AsyncMock()
+ async def test_message_without_attachments(self):
+ """If no link or attachments are provided, commands.BadArgument should be raised."""
+ self.ctx.message.attachments = []
- self.ctx.reset_mock()
- members = (MockMember() for _ in range(case))
- await self.cog.createteam(self.cog, self.ctx, "foo", members)
+ with self.assertRaises(BadArgument):
+ await self.cog.create(self.cog, self.ctx, None)
- self.ctx.send.assert_awaited_once()
- self.cog.create_channels.assert_not_awaited()
- self.cog.add_roles.assert_not_awaited()
+ async def test_result_sending(self):
+ """Should call `ctx.send` when everything goes right."""
+ self.ctx.message.attachments = [MockAttachment()]
+ self.ctx.message.attachments[0].read = AsyncMock()
+ self.ctx.message.attachments[0].read.return_value = TEST_CSV
+
+ team_leaders = MockRole()
+
+ self.guild.get_member.return_value = MockMember()
- async def test_duplicate_members_provided(self):
- """Should `ctx.send` and exit early because duplicate members provided and total there is only 1 member."""
- self.cog.create_channels = AsyncMock()
+ self.ctx.guild.create_role = AsyncMock()
+ self.ctx.guild.create_role.return_value = team_leaders
+ self.cog.create_team_channel = AsyncMock()
+ self.cog.create_team_leader_channel = AsyncMock()
self.cog.add_roles = AsyncMock()
- member = MockMember()
- await self.cog.createteam(self.cog, self.ctx, "foo", (member for _ in range(5)))
+ await self.cog.create(self.cog, self.ctx, None)
+ self.cog.create_team_channel.assert_awaited()
+ self.cog.create_team_leader_channel.assert_awaited_once_with(
+ self.ctx.guild, team_leaders
+ )
self.ctx.send.assert_awaited_once()
- self.cog.create_channels.assert_not_awaited()
- self.cog.add_roles.assert_not_awaited()
-
- async def test_result_sending(self):
- """Should call `ctx.send` when everything goes right."""
- self.cog.create_channels = AsyncMock()
- self.cog.add_roles = AsyncMock()
- members = [MockMember() for _ in range(5)]
- await self.cog.createteam(self.cog, self.ctx, "foo", members)
+ async def test_link_returning_non_200_status(self):
+ """When the URL passed returns a non 200 status, it should send a message informing them."""
+ self.bot.http_session.get.return_value = mock = MagicMock()
+ mock.status = 404
+ await self.cog.create(self.cog, self.ctx, "https://not-a-real-link.com")
- self.cog.create_channels.assert_awaited_once()
- self.cog.add_roles.assert_awaited_once()
self.ctx.send.assert_awaited_once()
async def test_category_doesnt_exist(self):
"""Should create a new code jam category."""
subtests = (
[],
- [get_mock_category(jams.MAX_CHANNELS - 1, jams.CATEGORY_NAME)],
+ [get_mock_category(jams.MAX_CHANNELS, jams.CATEGORY_NAME)],
[get_mock_category(jams.MAX_CHANNELS - 2, "other")],
)
+ self.cog.send_status_update = AsyncMock()
+
for categories in subtests:
+ self.cog.send_status_update.reset_mock()
self.guild.reset_mock()
self.guild.categories = categories
with self.subTest(categories=categories):
actual_category = await self.cog.get_category(self.guild)
+ self.cog.send_status_update.assert_called_once()
self.guild.create_category_channel.assert_awaited_once()
category_overwrites = self.guild.create_category_channel.call_args[1]["overwrites"]
@@ -103,62 +121,47 @@ class JamCreateTeamTests(unittest.IsolatedAsyncioTestCase):
async def test_channel_overwrites(self):
"""Should have correct permission overwrites for users and roles."""
- leader = MockMember()
- members = [leader] + [MockMember() for _ in range(4)]
+ leader = (MockMember(), True)
+ members = [leader] + [(MockMember(), False) for _ in range(4)]
overwrites = self.cog.get_overwrites(members, self.guild)
- # Leader permission overwrites
- self.assertTrue(overwrites[leader].manage_messages)
- self.assertTrue(overwrites[leader].read_messages)
- self.assertTrue(overwrites[leader].manage_webhooks)
- self.assertTrue(overwrites[leader].connect)
-
- # Other members permission overwrites
- for member in members[1:]:
+ for member, _ in members:
self.assertTrue(overwrites[member].read_messages)
- self.assertTrue(overwrites[member].connect)
-
- # Everyone role overwrite
- self.assertFalse(overwrites[self.guild.default_role].read_messages)
- self.assertFalse(overwrites[self.guild.default_role].connect)
async def test_team_channels_creation(self):
- """Should create new voice and text channel for team."""
- members = [MockMember() for _ in range(5)]
+ """Should create a text channel for a team."""
+ team_leaders = MockRole()
+ members = [(MockMember(), True)] + [(MockMember(), False) for _ in range(5)]
+ category = MockCategoryChannel()
+ category.create_text_channel = AsyncMock()
self.cog.get_overwrites = MagicMock()
self.cog.get_category = AsyncMock()
- self.ctx.guild.create_text_channel.return_value = MockTextChannel(mention="foobar-channel")
- actual = await self.cog.create_channels(self.guild, "my-team", members)
+ self.cog.get_category.return_value = category
+ self.cog.add_team_leader_roles = AsyncMock()
- self.assertEqual("foobar-channel", actual)
+ await self.cog.create_team_channel(self.guild, "my-team", members, team_leaders)
+ self.cog.add_team_leader_roles.assert_awaited_once_with(members, team_leaders)
self.cog.get_overwrites.assert_called_once_with(members, self.guild)
self.cog.get_category.assert_awaited_once_with(self.guild)
- self.guild.create_text_channel.assert_awaited_once_with(
+ category.create_text_channel.assert_awaited_once_with(
"my-team",
- overwrites=self.cog.get_overwrites.return_value,
- category=self.cog.get_category.return_value
- )
- self.guild.create_voice_channel.assert_awaited_once_with(
- "My Team",
- overwrites=self.cog.get_overwrites.return_value,
- category=self.cog.get_category.return_value
+ overwrites=self.cog.get_overwrites.return_value
)
async def test_jam_roles_adding(self):
"""Should add team leader role to leader and jam role to every team member."""
leader_role = MockRole(name="Team Leader")
- jam_role = MockRole(name="Jammer")
- self.guild.get_role.side_effect = [leader_role, jam_role]
leader = MockMember()
- members = [leader] + [MockMember() for _ in range(4)]
- await self.cog.add_roles(self.guild, members)
+ members = [(leader, True)] + [(MockMember(), False) for _ in range(4)]
+ await self.cog.add_team_leader_roles(members, leader_role)
- leader.add_roles.assert_any_await(leader_role)
- for member in members:
- member.add_roles.assert_any_await(jam_role)
+ leader.add_roles.assert_awaited_once_with(leader_role)
+ for member, is_leader in members:
+ if not is_leader:
+ member.add_roles.assert_not_awaited()
class CodeJamSetup(unittest.TestCase):
diff --git a/tests/helpers.py b/tests/helpers.py
index e3dc5fe5b..eedd7a601 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -361,6 +361,27 @@ class MockDMChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
super().__init__(**collections.ChainMap(kwargs, default_kwargs))
+# Create CategoryChannel instance to get a realistic MagicMock of `discord.CategoryChannel`
+category_channel_data = {
+ 'id': 1,
+ 'type': discord.ChannelType.category,
+ 'name': 'category',
+ 'position': 1,
+}
+
+state = unittest.mock.MagicMock()
+guild = unittest.mock.MagicMock()
+category_channel_instance = discord.CategoryChannel(
+ state=state, guild=guild, data=category_channel_data
+)
+
+
+class MockCategoryChannel(CustomMockMixin, unittest.mock.Mock, HashableMixin):
+ def __init__(self, **kwargs) -> None:
+ default_kwargs = {'id': next(self.discord_id)}
+ super().__init__(**collections.ChainMap(default_kwargs, kwargs))
+
+
# Create a Message instance to get a realistic MagicMock of `discord.Message`
message_data = {
'id': 1,
@@ -403,6 +424,7 @@ class MockContext(CustomMockMixin, unittest.mock.MagicMock):
self.guild = kwargs.get('guild', MockGuild())
self.author = kwargs.get('author', MockMember())
self.channel = kwargs.get('channel', MockTextChannel())
+ self.message = kwargs.get('message', MockMessage())
self.invoked_from_error_handler = kwargs.get('invoked_from_error_handler', False)